SaylorTwift HF Staff commited on
Commit
52efc7b
·
verified ·
1 Parent(s): 9332ccf

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/cli/src/ui/__snapshots__/App.test.tsx.snap +152 -0
  2. packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg +294 -0
  3. packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +43 -0
  4. packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap +19 -0
  5. packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap +60 -0
  6. packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap +17 -0
  7. packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap +17 -0
  8. packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap +12 -0
  9. packages/cli/src/ui/commands/aboutCommand.test.ts +181 -0
  10. packages/cli/src/ui/commands/aboutCommand.ts +77 -0
  11. packages/cli/src/ui/commands/agentsCommand.test.ts +514 -0
  12. packages/cli/src/ui/commands/agentsCommand.ts +394 -0
  13. packages/cli/src/ui/commands/authCommand.test.ts +138 -0
  14. packages/cli/src/ui/commands/authCommand.ts +58 -0
  15. packages/cli/src/ui/commands/bugCommand.test.ts +344 -0
  16. packages/cli/src/ui/commands/bugCommand.ts +194 -0
  17. packages/cli/src/ui/commands/bugMemoryCommand.test.ts +121 -0
  18. packages/cli/src/ui/commands/bugMemoryCommand.ts +86 -0
  19. packages/cli/src/ui/commands/chatCommand.test.ts +763 -0
  20. packages/cli/src/ui/commands/chatCommand.ts +430 -0
  21. packages/cli/src/ui/commands/clearCommand.test.ts +119 -0
  22. packages/cli/src/ui/commands/clearCommand.ts +88 -0
  23. packages/cli/src/ui/commands/commandsCommand.test.ts +157 -0
  24. packages/cli/src/ui/commands/commandsCommand.ts +144 -0
  25. packages/cli/src/ui/commands/compressCommand.test.ts +146 -0
  26. packages/cli/src/ui/commands/compressCommand.ts +86 -0
  27. packages/cli/src/ui/commands/copyCommand.test.ts +317 -0
  28. packages/cli/src/ui/commands/copyCommand.ts +70 -0
  29. packages/cli/src/ui/commands/corgiCommand.test.ts +34 -0
  30. packages/cli/src/ui/commands/corgiCommand.ts +18 -0
  31. packages/cli/src/ui/commands/directoryCommand.test.tsx +461 -0
  32. packages/cli/src/ui/commands/directoryCommand.tsx +299 -0
  33. packages/cli/src/ui/commands/docsCommand.test.ts +99 -0
  34. packages/cli/src/ui/commands/docsCommand.ts +43 -0
  35. packages/cli/src/ui/commands/editorCommand.test.ts +30 -0
  36. packages/cli/src/ui/commands/editorCommand.ts +22 -0
  37. packages/cli/src/ui/commands/exportSessionCommand.test.ts +135 -0
  38. packages/cli/src/ui/commands/exportSessionCommand.ts +98 -0
  39. packages/cli/src/ui/commands/extensionsCommand.test.ts +1227 -0
  40. packages/cli/src/ui/commands/extensionsCommand.ts +916 -0
  41. packages/cli/src/ui/commands/footerCommand.tsx +25 -0
  42. packages/cli/src/ui/commands/gemmaStatusCommand.ts +41 -0
  43. packages/cli/src/ui/commands/helpCommand.test.ts +133 -0
  44. packages/cli/src/ui/commands/helpCommand.ts +46 -0
  45. packages/cli/src/ui/commands/hooksCommand.test.ts +835 -0
  46. packages/cli/src/ui/commands/hooksCommand.ts +411 -0
  47. packages/cli/src/ui/commands/ideCommand.test.ts +274 -0
  48. packages/cli/src/ui/commands/ideCommand.ts +324 -0
  49. packages/cli/src/ui/commands/initCommand.test.ts +112 -0
  50. packages/cli/src/ui/commands/initCommand.ts +54 -0
packages/cli/src/ui/__snapshots__/App.test.tsx.snap ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`App > Snapshots > renders default layout correctly 1`] = `
4
+ "
5
+ ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
6
+ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
7
+ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
8
+ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
9
+
10
+ Gemini CLI v1.2.3
11
+
12
+
13
+
14
+ Tips for getting started:
15
+ 1. Create GEMINI.md files to customize your interactions
16
+ 2. /help for more information
17
+ 3. Ask coding questions, edit code or run commands
18
+ 4. Be specific for the best results
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ Notifications
41
+
42
+ Composer
43
+ "
44
+ `;
45
+
46
+ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
47
+ "Notifications
48
+ Footer
49
+
50
+ ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
51
+ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
52
+ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
53
+ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
54
+
55
+ Gemini CLI v1.2.3
56
+
57
+
58
+
59
+ Tips for getting started:
60
+ 1. Create GEMINI.md files to customize your interactions
61
+ 2. /help for more information
62
+ 3. Ask coding questions, edit code or run commands
63
+ 4. Be specific for the best results
64
+ Composer
65
+ "
66
+ `;
67
+
68
+ exports[`App > Snapshots > renders with dialogs visible 1`] = `
69
+ "
70
+ ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
71
+ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
72
+ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
73
+ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
74
+
75
+ Gemini CLI v1.2.3
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+ Notifications
106
+
107
+ DialogManager
108
+ "
109
+ `;
110
+
111
+ exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
112
+ "
113
+ ▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
114
+ ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
115
+ ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
116
+ ▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
117
+
118
+ Gemini CLI v1.2.3
119
+
120
+
121
+
122
+ Tips for getting started:
123
+ 1. Create GEMINI.md files to customize your interactions
124
+ 2. /help for more information
125
+ 3. Ask coding questions, edit code or run commands
126
+ 4. Be specific for the best results
127
+ HistoryItemDisplay
128
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
129
+ │ Action Required │
130
+ │ │
131
+ │ ? ls list directory │
132
+ │ │
133
+ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
134
+ │ │ ls │ │
135
+ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
136
+ │ Allow execution of [ls]? │
137
+ │ │
138
+ │ ● 1. Allow once │
139
+ │ 2. Allow for this session │
140
+ │ 3. No, suggest changes (esc) │
141
+ ╰──────────────────────────────���───────────────────────────────────────────────────────────────────╯
142
+
143
+
144
+
145
+
146
+
147
+
148
+ Notifications
149
+
150
+ Composer
151
+ "
152
+ `;
packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg ADDED
packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
4
+ " > Can you edit InputPrompt.tsx for me?
5
+ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
6
+
7
+ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
8
+ │ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
9
+ │ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │
10
+ │ │ ... first 42 lines hidden (Ctrl+O to show) ... │ │
11
+ │ │ 43 const line43 = true; │ │
12
+ │ │ 44 const line44 = true; │ │
13
+ │ │ 45 const line45 = true; │ │
14
+ │ │ 46 const line46 = true; │ │
15
+ │ │ 47 const line47 = true; │ │▄
16
+ │ │ 48 const line48 = true; │ │█
17
+ │ │ 49 const line49 = true; │ │█
18
+ │ │ 50 const line50 = true; │ │█
19
+ │ │ 51 const line51 = true; │ │█
20
+ │ │ 52 const line52 = true; │ │█
21
+ │ │ 53 const line53 = true; │ │█
22
+ │ │ 54 const line54 = true; │ │█
23
+ │ │ 55 const line55 = true; │ │█
24
+ │ │ 56 const line56 = true; │ │█
25
+ │ │ 57 const line57 = true; │ │█
26
+ │ │ 58 const line58 = true; │ │█
27
+ │ │ 59 const line59 = true; │ │█
28
+ │ │ 60 const line60 = true; │ │█
29
+ │ │ 61 - return kittyProtocolSupporte...; │ │█
30
+ │ │ 61 + return kittyProtocolSupporte...; │ │█
31
+ │ │ 62 buffer: TextBuffer; │ │█
32
+ │ │ 63 onSubmit: (value: string) => void; │ │█
33
+ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ │█
34
+ │ Apply this change? │█
35
+ │ │█
36
+ │ ● 1. Allow once │█
37
+ │ 2. Allow for this session │█
38
+ │ 3. Allow for this file in all future sessions ~/.gemini/policies/auto-saved.toml │█
39
+ │ 4. Modify with external editor │█
40
+ │ 5. No, suggest changes (esc) │█
41
+ ╰────────────────────────────────────────────────────────────��────────────────────────────────────╯█
42
+ "
43
+ `;
packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ApiAuthDialog > renders correctly 1`] = `
4
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
5
+ │ │
6
+ │ Enter Gemini API Key │
7
+ │ │
8
+ │ Please enter your Gemini API key. It will be securely stored in your system keychain. │
9
+ │ You can get an API key from https://aistudio.google.com/app/apikey │
10
+ │ │
11
+ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
12
+ │ │ Paste your API key here │ │
13
+ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
14
+ │ │
15
+ │ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │
16
+ │ │
17
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
18
+ "
19
+ `;
packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
4
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
5
+ │ │
6
+ │ ? Get started │
7
+ │ │
8
+ │ How would you like to authenticate for this project? │
9
+ │ │
10
+ │ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
11
+ │ │
12
+ │ Something went wrong │
13
+ │ │
14
+ │ (Use Enter to select) │
15
+ │ │
16
+ │ Terms of Services and Privacy Notice for Gemini CLI │
17
+ │ │
18
+ │ https://geminicli.com/docs/resources/tos-privacy/ │
19
+ │ │
20
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
21
+ "
22
+ `;
23
+
24
+ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
25
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
26
+ │ │
27
+ │ ? Get started │
28
+ │ │
29
+ │ How would you like to authenticate for this project? │
30
+ │ │
31
+ │ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
32
+ │ │
33
+ │ (Use Enter to select) │
34
+ │ │
35
+ │ Terms of Services and Privacy Notice for Gemini CLI │
36
+ │ │
37
+ │ https://geminicli.com/docs/resources/tos-privacy/ │
38
+ │ │
39
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
40
+ "
41
+ `;
42
+
43
+ exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
44
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
45
+ │ │
46
+ │ ? Get started │
47
+ │ │
48
+ │ How would you like to authenticate for this project? │
49
+ │ │
50
+ │ (selected) Use Gemini API Key │
51
+ │ │
52
+ │ (Use Enter to select) │
53
+ │ │
54
+ │ Terms of Services and Privacy Notice for Gemini CLI │
55
+ │ │
56
+ │ https://geminicli.com/docs/resources/tos-privacy/ │
57
+ │ │
58
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
59
+ "
60
+ `;
packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`BannedAccountDialog > renders snapshot correctly 1`] = `
4
+ "
5
+ Error: Account Suspended
6
+
7
+ This service has been disabled in this account for violation of Terms of Service. Please submit an
8
+ appeal to continue using this product.
9
+
10
+ Appeal URL:
11
+ [https://example.com/appeal]
12
+
13
+ Appeal HereChange authenticationExit
14
+
15
+ Escape to exit
16
+ "
17
+ `;
packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`LoginRestartDialog > renders correctly with custom message 1`] = `
4
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
5
+ │ Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings. │
6
+ │ Press R to restart, or Esc to choose a different authentication method. │
7
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
8
+ "
9
+ `;
10
+
11
+ exports[`LoginRestartDialog > renders correctly with default message 1`] = `
12
+ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
13
+ │ You've successfully signed in with Google. Gemini CLI needs to be restarted. │
14
+ │ Press R to restart, or Esc to choose a different authentication method. │
15
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
16
+ "
17
+ `;
packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`commandsCommand > list > should list .toml files from available sources 1`] = `
4
+ "### User Commands (/mock/user/commands)
5
+ - user1.toml
6
+ ### Project Commands (/mock/project/commands)
7
+ - proj1.toml
8
+ ### Extension: ext1 Commands (/mock/ext1/commands)
9
+ - ext1.toml
10
+
11
+ _Note: MCP prompts are dynamically loaded from configured MCP servers._"
12
+ `;
packages/cli/src/ui/commands/aboutCommand.test.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
8
+ import { aboutCommand } from './aboutCommand.js';
9
+ import { type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+ import { MessageType } from '../types.js';
12
+ import { IdeClient, getVersion } from '@google/gemini-cli-core';
13
+
14
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
15
+ const actual =
16
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
17
+ return {
18
+ ...actual,
19
+ IdeClient: {
20
+ getInstance: vi.fn().mockResolvedValue({
21
+ getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
22
+ }),
23
+ },
24
+ UserAccountManager: vi.fn().mockImplementation(() => ({
25
+ getCachedGoogleAccount: vi.fn().mockReturnValue('test-email@example.com'),
26
+ })),
27
+ getVersion: vi.fn(),
28
+ };
29
+ });
30
+
31
+ describe('aboutCommand', () => {
32
+ let mockContext: CommandContext;
33
+ const originalPlatform = process.platform;
34
+ const originalEnv = { ...process.env };
35
+
36
+ beforeEach(() => {
37
+ mockContext = createMockCommandContext({
38
+ services: {
39
+ agentContext: {
40
+ config: {
41
+ getModel: vi.fn(),
42
+ getIdeMode: vi.fn().mockReturnValue(true),
43
+ getUserTierName: vi.fn().mockReturnValue(undefined),
44
+ },
45
+ },
46
+ settings: {
47
+ merged: {
48
+ security: {
49
+ auth: {
50
+ selectedType: 'test-auth',
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ ui: {
57
+ addItem: vi.fn(),
58
+ },
59
+ } as unknown as CommandContext);
60
+
61
+ vi.mocked(getVersion).mockResolvedValue('test-version');
62
+ vi.spyOn(
63
+ mockContext.services.agentContext!.config,
64
+ 'getModel',
65
+ ).mockReturnValue('test-model');
66
+ process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
67
+ Object.defineProperty(process, 'platform', {
68
+ value: 'test-os',
69
+ });
70
+ });
71
+
72
+ afterEach(() => {
73
+ vi.unstubAllEnvs();
74
+ Object.defineProperty(process, 'platform', {
75
+ value: originalPlatform,
76
+ });
77
+ process.env = originalEnv;
78
+ vi.clearAllMocks();
79
+ });
80
+
81
+ it('should have the correct name and description', () => {
82
+ expect(aboutCommand.name).toBe('about');
83
+ expect(aboutCommand.description).toBe('Show version info');
84
+ });
85
+
86
+ it('should call addItem with all version info', async () => {
87
+ process.env['SANDBOX'] = '';
88
+ if (!aboutCommand.action) {
89
+ throw new Error('The about command must have an action.');
90
+ }
91
+
92
+ await aboutCommand.action(mockContext, '');
93
+
94
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
95
+ type: MessageType.ABOUT,
96
+ cliVersion: 'test-version',
97
+ osVersion: 'test-os',
98
+ sandboxEnv: 'no sandbox',
99
+ modelVersion: 'test-model',
100
+ selectedAuthType: 'test-auth',
101
+ gcpProject: 'test-gcp-project',
102
+ ideClient: 'test-ide',
103
+ userEmail: 'test-email@example.com',
104
+ tier: undefined,
105
+ });
106
+ });
107
+
108
+ it('should show the correct sandbox environment variable', async () => {
109
+ process.env['SANDBOX'] = 'gemini-sandbox';
110
+ if (!aboutCommand.action) {
111
+ throw new Error('The about command must have an action.');
112
+ }
113
+
114
+ await aboutCommand.action(mockContext, '');
115
+
116
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ sandboxEnv: 'gemini-sandbox',
119
+ }),
120
+ );
121
+ });
122
+
123
+ it('should show sandbox-exec profile when applicable', async () => {
124
+ process.env['SANDBOX'] = 'sandbox-exec';
125
+ process.env['SEATBELT_PROFILE'] = 'test-profile';
126
+ if (!aboutCommand.action) {
127
+ throw new Error('The about command must have an action.');
128
+ }
129
+
130
+ await aboutCommand.action(mockContext, '');
131
+
132
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
133
+ expect.objectContaining({
134
+ sandboxEnv: 'sandbox-exec (test-profile)',
135
+ }),
136
+ );
137
+ });
138
+
139
+ it('should not show ide client when it is not detected', async () => {
140
+ vi.mocked(IdeClient.getInstance).mockResolvedValue({
141
+ getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
142
+ } as unknown as IdeClient);
143
+
144
+ process.env['SANDBOX'] = '';
145
+ if (!aboutCommand.action) {
146
+ throw new Error('The about command must have an action.');
147
+ }
148
+
149
+ await aboutCommand.action(mockContext, '');
150
+
151
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ type: MessageType.ABOUT,
154
+ cliVersion: 'test-version',
155
+ osVersion: 'test-os',
156
+ sandboxEnv: 'no sandbox',
157
+ modelVersion: 'test-model',
158
+ selectedAuthType: 'test-auth',
159
+ gcpProject: 'test-gcp-project',
160
+ ideClient: '',
161
+ }),
162
+ );
163
+ });
164
+
165
+ it('should display the tier when getUserTierName returns a value', async () => {
166
+ vi.mocked(
167
+ mockContext.services.agentContext!.config.getUserTierName,
168
+ ).mockReturnValue('Enterprise Tier');
169
+ if (!aboutCommand.action) {
170
+ throw new Error('The about command must have an action.');
171
+ }
172
+
173
+ await aboutCommand.action(mockContext, '');
174
+
175
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
176
+ expect.objectContaining({
177
+ tier: 'Enterprise Tier',
178
+ }),
179
+ );
180
+ });
181
+ });
packages/cli/src/ui/commands/aboutCommand.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ CommandKind,
9
+ type CommandContext,
10
+ type SlashCommand,
11
+ } from './types.js';
12
+ import process from 'node:process';
13
+ import { MessageType, type HistoryItemAbout } from '../types.js';
14
+ import {
15
+ IdeClient,
16
+ UserAccountManager,
17
+ debugLogger,
18
+ getVersion,
19
+ } from '@google/gemini-cli-core';
20
+
21
+ export const aboutCommand: SlashCommand = {
22
+ name: 'about',
23
+ description: 'Show version info',
24
+ kind: CommandKind.BUILT_IN,
25
+ autoExecute: true,
26
+ isSafeConcurrent: true,
27
+ action: async (context) => {
28
+ const osVersion = process.platform;
29
+ let sandboxEnv = 'no sandbox';
30
+ if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
31
+ sandboxEnv = process.env['SANDBOX'];
32
+ } else if (process.env['SANDBOX'] === 'sandbox-exec') {
33
+ sandboxEnv = `sandbox-exec (${
34
+ process.env['SEATBELT_PROFILE'] || 'unknown'
35
+ })`;
36
+ }
37
+ const modelVersion =
38
+ context.services.agentContext?.config.getModel() || 'Unknown';
39
+ const cliVersion = await getVersion();
40
+ const selectedAuthType =
41
+ context.services.settings.merged.security.auth.selectedType || '';
42
+ const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
43
+ const ideClient = await getIdeClientName(context);
44
+
45
+ const userAccountManager = new UserAccountManager();
46
+ const cachedAccount = userAccountManager.getCachedGoogleAccount();
47
+ debugLogger.log('AboutCommand: Retrieved cached Google account', {
48
+ cachedAccount,
49
+ });
50
+ const userEmail = cachedAccount ?? undefined;
51
+
52
+ const tier = context.services.agentContext?.config.getUserTierName();
53
+
54
+ const aboutItem: Omit<HistoryItemAbout, 'id'> = {
55
+ type: MessageType.ABOUT,
56
+ cliVersion,
57
+ osVersion,
58
+ sandboxEnv,
59
+ modelVersion,
60
+ selectedAuthType,
61
+ gcpProject,
62
+ ideClient,
63
+ userEmail,
64
+ tier,
65
+ };
66
+
67
+ context.ui.addItem(aboutItem);
68
+ },
69
+ };
70
+
71
+ async function getIdeClientName(context: CommandContext) {
72
+ if (!context.services.agentContext?.config.getIdeMode()) {
73
+ return '';
74
+ }
75
+ const ideClient = await IdeClient.getInstance();
76
+ return ideClient?.getDetectedIdeDisplayName() ?? '';
77
+ }
packages/cli/src/ui/commands/agentsCommand.test.ts ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
8
+ import { agentsCommand } from './agentsCommand.js';
9
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
10
+ import type { Config } from '@google/gemini-cli-core';
11
+ import type { LoadedSettings } from '../../config/settings.js';
12
+ import { MessageType } from '../types.js';
13
+ import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
14
+ import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
15
+
16
+ vi.mock('../../utils/agentSettings.js', () => ({
17
+ enableAgent: vi.fn(),
18
+ disableAgent: vi.fn(),
19
+ }));
20
+
21
+ vi.mock('../../utils/agentUtils.js', () => ({
22
+ renderAgentActionFeedback: vi.fn(),
23
+ }));
24
+
25
+ describe('agentsCommand', () => {
26
+ let mockContext: ReturnType<typeof createMockCommandContext>;
27
+ let mockConfig: {
28
+ getAgentRegistry: ReturnType<typeof vi.fn>;
29
+ config: Config;
30
+ };
31
+
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+
35
+ mockConfig = {
36
+ getAgentRegistry: vi.fn().mockReturnValue({
37
+ getAllDefinitions: vi.fn().mockReturnValue([]),
38
+ getAllAgentNames: vi.fn().mockReturnValue([]),
39
+ reload: vi.fn(),
40
+ }),
41
+ get config() {
42
+ return this as unknown as Config;
43
+ },
44
+ };
45
+
46
+ mockContext = createMockCommandContext({
47
+ services: {
48
+ agentContext: mockConfig as unknown as Config,
49
+ settings: {
50
+ workspace: { path: '/mock/path' },
51
+ merged: { agents: { overrides: {} } },
52
+ } as unknown as LoadedSettings,
53
+ },
54
+ });
55
+ });
56
+
57
+ it('should show an error if config is not available', async () => {
58
+ const contextWithoutConfig = createMockCommandContext({
59
+ services: {
60
+ agentContext: null,
61
+ },
62
+ });
63
+
64
+ const result = await agentsCommand.action!(contextWithoutConfig, '');
65
+
66
+ expect(result).toEqual({
67
+ type: 'message',
68
+ messageType: 'error',
69
+ content: 'Config not loaded.',
70
+ });
71
+ });
72
+
73
+ it('should show an error if agent registry is not available', async () => {
74
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
75
+
76
+ const result = await agentsCommand.action!(mockContext, '');
77
+
78
+ expect(result).toEqual({
79
+ type: 'message',
80
+ messageType: 'error',
81
+ content: 'Agent registry not found.',
82
+ });
83
+ });
84
+
85
+ it('should call addItem with correct agents list', async () => {
86
+ const mockAgents = [
87
+ {
88
+ name: 'agent1',
89
+ displayName: 'Agent One',
90
+ description: 'desc1',
91
+ kind: 'local',
92
+ },
93
+ {
94
+ name: 'agent2',
95
+ displayName: undefined,
96
+ description: 'desc2',
97
+ kind: 'remote',
98
+ },
99
+ ];
100
+ mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents);
101
+
102
+ await agentsCommand.action!(mockContext, '');
103
+
104
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ type: MessageType.AGENTS_LIST,
107
+ agents: mockAgents,
108
+ }),
109
+ );
110
+ });
111
+
112
+ it('should reload the agent registry when reload subcommand is called', async () => {
113
+ const reloadSpy = vi.fn().mockResolvedValue({
114
+ totalLoaded: 3,
115
+ localCount: 2,
116
+ remoteCount: 1,
117
+ newAgents: ['new-agent'],
118
+ updatedAgents: ['updated-agent'],
119
+ deletedAgents: ['deleted-agent'],
120
+ errors: [],
121
+ });
122
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
123
+ reload: reloadSpy,
124
+ });
125
+
126
+ const reloadCommand = agentsCommand.subCommands?.find(
127
+ (cmd) => cmd.name === 'reload',
128
+ );
129
+ expect(reloadCommand).toBeDefined();
130
+
131
+ const result = (await reloadCommand!.action!(mockContext, '')) as {
132
+ type: 'message';
133
+ content: string;
134
+ };
135
+
136
+ expect(reloadSpy).toHaveBeenCalled();
137
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ type: MessageType.INFO,
140
+ text: 'Reloading agent registry...',
141
+ }),
142
+ );
143
+ expect(result).toEqual({
144
+ type: 'message',
145
+ messageType: 'info',
146
+ content: expect.stringContaining('Agents reloaded successfully:'),
147
+ });
148
+ expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
149
+ expect(result.content).toContain('- New: new-agent');
150
+ expect(result.content).toContain('- Updated: updated-agent');
151
+ expect(result.content).toContain('- Deleted: deleted-agent');
152
+ expect(result.content).toContain(
153
+ 'Run /agents list to see all available agents.',
154
+ );
155
+ });
156
+
157
+ it('should show "reloaded with errors" if errors occurred during reload', async () => {
158
+ const reloadSpy = vi.fn().mockResolvedValue({
159
+ totalLoaded: 1,
160
+ localCount: 1,
161
+ remoteCount: 0,
162
+ newAgents: [],
163
+ updatedAgents: [],
164
+ deletedAgents: [],
165
+ errors: ['Some error'],
166
+ });
167
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
168
+ reload: reloadSpy,
169
+ });
170
+
171
+ const reloadCommand = agentsCommand.subCommands?.find(
172
+ (cmd) => cmd.name === 'reload',
173
+ );
174
+
175
+ const result = (await reloadCommand!.action!(mockContext, '')) as {
176
+ type: 'message';
177
+ content: string;
178
+ };
179
+
180
+ expect(result.content).toContain('Agents reloaded with errors:');
181
+ expect(result.content).toContain('- Errors: 1 encountered during reload');
182
+ });
183
+
184
+ it('should show an error if agent registry is not available during reload', async () => {
185
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
186
+
187
+ const reloadCommand = agentsCommand.subCommands?.find(
188
+ (cmd) => cmd.name === 'reload',
189
+ );
190
+ const result = await reloadCommand!.action!(mockContext, '');
191
+
192
+ expect(result).toEqual({
193
+ type: 'message',
194
+ messageType: 'error',
195
+ content: 'Agent registry not found.',
196
+ });
197
+ });
198
+
199
+ it('should enable an agent successfully', async () => {
200
+ const reloadSpy = vi.fn().mockResolvedValue(undefined);
201
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
202
+ getAllAgentNames: vi.fn().mockReturnValue([]),
203
+ reload: reloadSpy,
204
+ });
205
+ // Add agent to disabled overrides so validation passes
206
+ mockContext.services.settings.merged.agents.overrides['test-agent'] = {
207
+ enabled: false,
208
+ };
209
+
210
+ vi.mocked(enableAgent).mockReturnValue({
211
+ status: 'success',
212
+ agentName: 'test-agent',
213
+ action: 'enable',
214
+ modifiedScopes: [],
215
+ alreadyInStateScopes: [],
216
+ });
217
+ vi.mocked(renderAgentActionFeedback).mockReturnValue('Enabled test-agent.');
218
+
219
+ const enableCommand = agentsCommand.subCommands?.find(
220
+ (cmd) => cmd.name === 'enable',
221
+ );
222
+ expect(enableCommand).toBeDefined();
223
+
224
+ const result = await enableCommand!.action!(mockContext, 'test-agent');
225
+
226
+ expect(enableAgent).toHaveBeenCalledWith(
227
+ mockContext.services.settings,
228
+ 'test-agent',
229
+ );
230
+ expect(reloadSpy).toHaveBeenCalled();
231
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
232
+ expect.objectContaining({
233
+ type: MessageType.INFO,
234
+ text: 'Enabling test-agent...',
235
+ }),
236
+ );
237
+ expect(result).toEqual({
238
+ type: 'message',
239
+ messageType: 'info',
240
+ content: 'Enabled test-agent.',
241
+ });
242
+ });
243
+
244
+ it('should handle no-op when enabling an agent', async () => {
245
+ mockConfig
246
+ .getAgentRegistry()
247
+ .getAllAgentNames.mockReturnValue(['test-agent']);
248
+
249
+ const enableCommand = agentsCommand.subCommands?.find(
250
+ (cmd) => cmd.name === 'enable',
251
+ );
252
+ const result = await enableCommand!.action!(mockContext, 'test-agent');
253
+
254
+ expect(enableAgent).not.toHaveBeenCalled();
255
+ expect(mockContext.ui.addItem).not.toHaveBeenCalled();
256
+ expect(result).toEqual({
257
+ type: 'message',
258
+ messageType: 'info',
259
+ content: "Agent 'test-agent' is already enabled.",
260
+ });
261
+ });
262
+
263
+ it('should show usage error if no agent name provided for enable', async () => {
264
+ const enableCommand = agentsCommand.subCommands?.find(
265
+ (cmd) => cmd.name === 'enable',
266
+ );
267
+ const result = await enableCommand!.action!(mockContext, ' ');
268
+
269
+ expect(result).toEqual({
270
+ type: 'message',
271
+ messageType: 'error',
272
+ content: 'Usage: /agents enable <agent-name>',
273
+ });
274
+ });
275
+
276
+ it('should show an error if config is not available for enable', async () => {
277
+ const contextWithoutConfig = createMockCommandContext({
278
+ services: { agentContext: null },
279
+ });
280
+ const enableCommand = agentsCommand.subCommands?.find(
281
+ (cmd) => cmd.name === 'enable',
282
+ );
283
+ const result = await enableCommand!.action!(contextWithoutConfig, 'test');
284
+ expect(result).toEqual({
285
+ type: 'message',
286
+ messageType: 'error',
287
+ content: 'Config not loaded.',
288
+ });
289
+ });
290
+
291
+ it('should disable an agent successfully', async () => {
292
+ const reloadSpy = vi.fn().mockResolvedValue(undefined);
293
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
294
+ getAllAgentNames: vi.fn().mockReturnValue(['test-agent']),
295
+ reload: reloadSpy,
296
+ });
297
+ vi.mocked(disableAgent).mockReturnValue({
298
+ status: 'success',
299
+ agentName: 'test-agent',
300
+ action: 'disable',
301
+ modifiedScopes: [],
302
+ alreadyInStateScopes: [],
303
+ });
304
+ vi.mocked(renderAgentActionFeedback).mockReturnValue(
305
+ 'Disabled test-agent.',
306
+ );
307
+
308
+ const disableCommand = agentsCommand.subCommands?.find(
309
+ (cmd) => cmd.name === 'disable',
310
+ );
311
+ expect(disableCommand).toBeDefined();
312
+
313
+ const result = await disableCommand!.action!(mockContext, 'test-agent');
314
+
315
+ expect(disableAgent).toHaveBeenCalledWith(
316
+ mockContext.services.settings,
317
+ 'test-agent',
318
+ expect.anything(), // Scope is derived in the command
319
+ );
320
+ expect(reloadSpy).toHaveBeenCalled();
321
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
322
+ expect.objectContaining({
323
+ type: MessageType.INFO,
324
+ text: 'Disabling test-agent...',
325
+ }),
326
+ );
327
+ expect(result).toEqual({
328
+ type: 'message',
329
+ messageType: 'info',
330
+ content: 'Disabled test-agent.',
331
+ });
332
+ });
333
+
334
+ it('should show info message if agent is already disabled', async () => {
335
+ mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
336
+ mockContext.services.settings.merged.agents.overrides['test-agent'] = {
337
+ enabled: false,
338
+ };
339
+
340
+ const disableCommand = agentsCommand.subCommands?.find(
341
+ (cmd) => cmd.name === 'disable',
342
+ );
343
+ const result = await disableCommand!.action!(mockContext, 'test-agent');
344
+
345
+ expect(disableAgent).not.toHaveBeenCalled();
346
+ expect(result).toEqual({
347
+ type: 'message',
348
+ messageType: 'info',
349
+ content: "Agent 'test-agent' is already disabled.",
350
+ });
351
+ });
352
+
353
+ it('should show error if agent is not found when disabling', async () => {
354
+ mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
355
+
356
+ const disableCommand = agentsCommand.subCommands?.find(
357
+ (cmd) => cmd.name === 'disable',
358
+ );
359
+ const result = await disableCommand!.action!(mockContext, 'test-agent');
360
+
361
+ expect(disableAgent).not.toHaveBeenCalled();
362
+ expect(result).toEqual({
363
+ type: 'message',
364
+ messageType: 'error',
365
+ content: "Agent 'test-agent' not found.",
366
+ });
367
+ });
368
+
369
+ it('should show usage error if no agent name provided for disable', async () => {
370
+ const disableCommand = agentsCommand.subCommands?.find(
371
+ (cmd) => cmd.name === 'disable',
372
+ );
373
+ const result = await disableCommand!.action!(mockContext, '');
374
+
375
+ expect(result).toEqual({
376
+ type: 'message',
377
+ messageType: 'error',
378
+ content: 'Usage: /agents disable <agent-name>',
379
+ });
380
+ });
381
+
382
+ it('should show an error if config is not available for disable', async () => {
383
+ const contextWithoutConfig = createMockCommandContext({
384
+ services: { agentContext: null },
385
+ });
386
+ const disableCommand = agentsCommand.subCommands?.find(
387
+ (cmd) => cmd.name === 'disable',
388
+ );
389
+ const result = await disableCommand!.action!(contextWithoutConfig, 'test');
390
+ expect(result).toEqual({
391
+ type: 'message',
392
+ messageType: 'error',
393
+ content: 'Config not loaded.',
394
+ });
395
+ });
396
+
397
+ describe('config sub-command', () => {
398
+ it('should return dialog action for a valid agent', async () => {
399
+ const mockDefinition = {
400
+ name: 'test-agent',
401
+ displayName: 'Test Agent',
402
+ description: 'test desc',
403
+ kind: 'local',
404
+ };
405
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
406
+ getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
407
+ });
408
+
409
+ const configCommand = agentsCommand.subCommands?.find(
410
+ (cmd) => cmd.name === 'config',
411
+ );
412
+ expect(configCommand).toBeDefined();
413
+
414
+ const result = await configCommand!.action!(mockContext, 'test-agent');
415
+
416
+ expect(result).toEqual({
417
+ type: 'dialog',
418
+ dialog: 'agentConfig',
419
+ props: {
420
+ name: 'test-agent',
421
+ displayName: 'Test Agent',
422
+ definition: mockDefinition,
423
+ },
424
+ });
425
+ });
426
+
427
+ it('should use name as displayName if displayName is missing', async () => {
428
+ const mockDefinition = {
429
+ name: 'test-agent',
430
+ description: 'test desc',
431
+ kind: 'local',
432
+ };
433
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
434
+ getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
435
+ });
436
+
437
+ const configCommand = agentsCommand.subCommands?.find(
438
+ (cmd) => cmd.name === 'config',
439
+ );
440
+ const result = await configCommand!.action!(mockContext, 'test-agent');
441
+
442
+ expect(result).toEqual({
443
+ type: 'dialog',
444
+ dialog: 'agentConfig',
445
+ props: {
446
+ name: 'test-agent',
447
+ displayName: 'test-agent', // Falls back to name
448
+ definition: mockDefinition,
449
+ },
450
+ });
451
+ });
452
+
453
+ it('should show error if agent is not found', async () => {
454
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
455
+ getDiscoveredDefinition: vi.fn().mockReturnValue(undefined),
456
+ });
457
+
458
+ const configCommand = agentsCommand.subCommands?.find(
459
+ (cmd) => cmd.name === 'config',
460
+ );
461
+ const result = await configCommand!.action!(mockContext, 'non-existent');
462
+
463
+ expect(result).toEqual({
464
+ type: 'message',
465
+ messageType: 'error',
466
+ content: "Agent 'non-existent' not found.",
467
+ });
468
+ });
469
+
470
+ it('should show usage error if no agent name provided', async () => {
471
+ const configCommand = agentsCommand.subCommands?.find(
472
+ (cmd) => cmd.name === 'config',
473
+ );
474
+ const result = await configCommand!.action!(mockContext, ' ');
475
+
476
+ expect(result).toEqual({
477
+ type: 'message',
478
+ messageType: 'error',
479
+ content: 'Usage: /agents config <agent-name>',
480
+ });
481
+ });
482
+
483
+ it('should show an error if config is not available', async () => {
484
+ const contextWithoutConfig = createMockCommandContext({
485
+ services: { agentContext: null },
486
+ });
487
+ const configCommand = agentsCommand.subCommands?.find(
488
+ (cmd) => cmd.name === 'config',
489
+ );
490
+ const result = await configCommand!.action!(contextWithoutConfig, 'test');
491
+ expect(result).toEqual({
492
+ type: 'message',
493
+ messageType: 'error',
494
+ content: 'Config not loaded.',
495
+ });
496
+ });
497
+
498
+ it('should provide completions for discovered agents', async () => {
499
+ mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
500
+ getAllDiscoveredAgentNames: vi
501
+ .fn()
502
+ .mockReturnValue(['agent1', 'agent2', 'other']),
503
+ });
504
+
505
+ const configCommand = agentsCommand.subCommands?.find(
506
+ (cmd) => cmd.name === 'config',
507
+ );
508
+ expect(configCommand?.completion).toBeDefined();
509
+
510
+ const completions = await configCommand!.completion!(mockContext, 'age');
511
+ expect(completions).toEqual(['agent1', 'agent2']);
512
+ });
513
+ });
514
+ });
packages/cli/src/ui/commands/agentsCommand.ts ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type {
8
+ SlashCommand,
9
+ CommandContext,
10
+ SlashCommandActionReturn,
11
+ } from './types.js';
12
+ import { CommandKind } from './types.js';
13
+ import { MessageType, type HistoryItemAgentsList } from '../types.js';
14
+ import { SettingScope } from '../../config/settings.js';
15
+ import { disableAgent, enableAgent } from '../../utils/agentSettings.js';
16
+ import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
17
+
18
+ const agentsListCommand: SlashCommand = {
19
+ name: 'list',
20
+ description: 'List available local and remote agents',
21
+ kind: CommandKind.BUILT_IN,
22
+ autoExecute: true,
23
+ action: async (context: CommandContext) => {
24
+ const config = context.services.agentContext?.config;
25
+ if (!config) {
26
+ return {
27
+ type: 'message',
28
+ messageType: 'error',
29
+ content: 'Config not loaded.',
30
+ };
31
+ }
32
+
33
+ const agentRegistry = config.getAgentRegistry();
34
+ if (!agentRegistry) {
35
+ return {
36
+ type: 'message',
37
+ messageType: 'error',
38
+ content: 'Agent registry not found.',
39
+ };
40
+ }
41
+
42
+ const agents = agentRegistry.getAllDefinitions().map((def) => ({
43
+ name: def.name,
44
+ displayName: def.displayName,
45
+ description: def.description,
46
+ kind: def.kind,
47
+ }));
48
+
49
+ const agentsListItem: HistoryItemAgentsList = {
50
+ type: MessageType.AGENTS_LIST,
51
+ agents,
52
+ };
53
+
54
+ context.ui.addItem(agentsListItem);
55
+
56
+ return;
57
+ },
58
+ };
59
+
60
+ async function enableAction(
61
+ context: CommandContext,
62
+ args: string,
63
+ ): Promise<SlashCommandActionReturn | void> {
64
+ const config = context.services.agentContext?.config;
65
+ const { settings } = context.services;
66
+ if (!config) {
67
+ return {
68
+ type: 'message',
69
+ messageType: 'error',
70
+ content: 'Config not loaded.',
71
+ };
72
+ }
73
+
74
+ const agentName = args.trim();
75
+ if (!agentName) {
76
+ return {
77
+ type: 'message',
78
+ messageType: 'error',
79
+ content: 'Usage: /agents enable <agent-name>',
80
+ };
81
+ }
82
+
83
+ const agentRegistry = config.getAgentRegistry();
84
+ if (!agentRegistry) {
85
+ return {
86
+ type: 'message',
87
+ messageType: 'error',
88
+ content: 'Agent registry not found.',
89
+ };
90
+ }
91
+
92
+ const allAgents = agentRegistry.getAllAgentNames();
93
+ const overrides = settings.merged.agents.overrides;
94
+ const disabledAgents = Object.keys(overrides).filter(
95
+ (name) => overrides[name]?.enabled === false,
96
+ );
97
+
98
+ if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
99
+ return {
100
+ type: 'message',
101
+ messageType: 'info',
102
+ content: `Agent '${agentName}' is already enabled.`,
103
+ };
104
+ }
105
+
106
+ if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) {
107
+ return {
108
+ type: 'message',
109
+ messageType: 'error',
110
+ content: `Agent '${agentName}' not found.`,
111
+ };
112
+ }
113
+
114
+ const result = enableAgent(settings, agentName);
115
+
116
+ if (result.status === 'no-op') {
117
+ return {
118
+ type: 'message',
119
+ messageType: 'info',
120
+ content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`),
121
+ };
122
+ }
123
+
124
+ context.ui.addItem({
125
+ type: MessageType.INFO,
126
+ text: `Enabling ${agentName}...`,
127
+ });
128
+ await agentRegistry.reload();
129
+
130
+ return {
131
+ type: 'message',
132
+ messageType: 'info',
133
+ content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`),
134
+ };
135
+ }
136
+
137
+ async function disableAction(
138
+ context: CommandContext,
139
+ args: string,
140
+ ): Promise<SlashCommandActionReturn | void> {
141
+ const config = context.services.agentContext?.config;
142
+ const { settings } = context.services;
143
+ if (!config) {
144
+ return {
145
+ type: 'message',
146
+ messageType: 'error',
147
+ content: 'Config not loaded.',
148
+ };
149
+ }
150
+
151
+ const agentName = args.trim();
152
+ if (!agentName) {
153
+ return {
154
+ type: 'message',
155
+ messageType: 'error',
156
+ content: 'Usage: /agents disable <agent-name>',
157
+ };
158
+ }
159
+
160
+ const agentRegistry = config.getAgentRegistry();
161
+ if (!agentRegistry) {
162
+ return {
163
+ type: 'message',
164
+ messageType: 'error',
165
+ content: 'Agent registry not found.',
166
+ };
167
+ }
168
+
169
+ const allAgents = agentRegistry.getAllAgentNames();
170
+ const overrides = settings.merged.agents.overrides;
171
+ const disabledAgents = Object.keys(overrides).filter(
172
+ (name) => overrides[name]?.enabled === false,
173
+ );
174
+
175
+ if (disabledAgents.includes(agentName)) {
176
+ return {
177
+ type: 'message',
178
+ messageType: 'info',
179
+ content: `Agent '${agentName}' is already disabled.`,
180
+ };
181
+ }
182
+
183
+ if (!allAgents.includes(agentName)) {
184
+ return {
185
+ type: 'message',
186
+ messageType: 'error',
187
+ content: `Agent '${agentName}' not found.`,
188
+ };
189
+ }
190
+
191
+ const scope = context.services.settings.workspace.path
192
+ ? SettingScope.Workspace
193
+ : SettingScope.User;
194
+ const result = disableAgent(settings, agentName, scope);
195
+
196
+ if (result.status === 'no-op') {
197
+ return {
198
+ type: 'message',
199
+ messageType: 'info',
200
+ content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`),
201
+ };
202
+ }
203
+
204
+ context.ui.addItem({
205
+ type: MessageType.INFO,
206
+ text: `Disabling ${agentName}...`,
207
+ });
208
+ await agentRegistry.reload();
209
+
210
+ return {
211
+ type: 'message',
212
+ messageType: 'info',
213
+ content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`),
214
+ };
215
+ }
216
+
217
+ async function configAction(
218
+ context: CommandContext,
219
+ args: string,
220
+ ): Promise<SlashCommandActionReturn | void> {
221
+ const config = context.services.agentContext?.config;
222
+ if (!config) {
223
+ return {
224
+ type: 'message',
225
+ messageType: 'error',
226
+ content: 'Config not loaded.',
227
+ };
228
+ }
229
+
230
+ const agentName = args.trim();
231
+ if (!agentName) {
232
+ return {
233
+ type: 'message',
234
+ messageType: 'error',
235
+ content: 'Usage: /agents config <agent-name>',
236
+ };
237
+ }
238
+
239
+ const agentRegistry = config.getAgentRegistry();
240
+ if (!agentRegistry) {
241
+ return {
242
+ type: 'message',
243
+ messageType: 'error',
244
+ content: 'Agent registry not found.',
245
+ };
246
+ }
247
+
248
+ const definition = agentRegistry.getDiscoveredDefinition(agentName);
249
+ if (!definition) {
250
+ return {
251
+ type: 'message',
252
+ messageType: 'error',
253
+ content: `Agent '${agentName}' not found.`,
254
+ };
255
+ }
256
+
257
+ const displayName = definition.displayName || agentName;
258
+
259
+ return {
260
+ type: 'dialog',
261
+ dialog: 'agentConfig',
262
+ props: {
263
+ name: agentName,
264
+ displayName,
265
+ definition,
266
+ },
267
+ };
268
+ }
269
+
270
+ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
271
+ const config = context.services.agentContext?.config;
272
+ const { settings } = context.services;
273
+ if (!config) return [];
274
+
275
+ const overrides = settings.merged.agents.overrides;
276
+ const disabledAgents = Object.entries(overrides)
277
+ .filter(([_, override]) => override?.enabled === false)
278
+ .map(([name]) => name);
279
+
280
+ return disabledAgents.filter((name) => name.startsWith(partialArg));
281
+ }
282
+
283
+ function completeAgentsToDisable(context: CommandContext, partialArg: string) {
284
+ const config = context.services.agentContext?.config;
285
+ if (!config) return [];
286
+
287
+ const agentRegistry = config.getAgentRegistry();
288
+ const allAgents = agentRegistry ? agentRegistry.getAllAgentNames() : [];
289
+ return allAgents.filter((name: string) => name.startsWith(partialArg));
290
+ }
291
+
292
+ function completeAllAgents(context: CommandContext, partialArg: string) {
293
+ const config = context.services.agentContext?.config;
294
+ if (!config) return [];
295
+
296
+ const agentRegistry = config.getAgentRegistry();
297
+ const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? [];
298
+ return allAgents.filter((name: string) => name.startsWith(partialArg));
299
+ }
300
+
301
+ const enableCommand: SlashCommand = {
302
+ name: 'enable',
303
+ description: 'Enable a disabled agent',
304
+ kind: CommandKind.BUILT_IN,
305
+ autoExecute: false,
306
+ action: enableAction,
307
+ completion: completeAgentsToEnable,
308
+ };
309
+
310
+ const disableCommand: SlashCommand = {
311
+ name: 'disable',
312
+ description: 'Disable an enabled agent',
313
+ kind: CommandKind.BUILT_IN,
314
+ autoExecute: false,
315
+ action: disableAction,
316
+ completion: completeAgentsToDisable,
317
+ };
318
+
319
+ const configCommand: SlashCommand = {
320
+ name: 'config',
321
+ description: 'Configure an agent',
322
+ kind: CommandKind.BUILT_IN,
323
+ autoExecute: false,
324
+ action: configAction,
325
+ completion: completeAllAgents,
326
+ };
327
+
328
+ const agentsReloadCommand: SlashCommand = {
329
+ name: 'reload',
330
+ altNames: ['refresh'],
331
+ description: 'Reload the agent registry',
332
+ kind: CommandKind.BUILT_IN,
333
+ action: async (context: CommandContext) => {
334
+ const config = context.services.agentContext?.config;
335
+ const agentRegistry = config?.getAgentRegistry();
336
+ if (!agentRegistry) {
337
+ return {
338
+ type: 'message',
339
+ messageType: 'error',
340
+ content: 'Agent registry not found.',
341
+ };
342
+ }
343
+
344
+ context.ui.addItem({
345
+ type: MessageType.INFO,
346
+ text: 'Reloading agent registry...',
347
+ });
348
+
349
+ const summary = await agentRegistry.reload();
350
+
351
+ let content =
352
+ summary.errors.length > 0
353
+ ? 'Agents reloaded with errors:'
354
+ : 'Agents reloaded successfully:';
355
+ content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
356
+
357
+ if (summary.newAgents.length > 0) {
358
+ content += `\n- New: ${summary.newAgents.join(', ')}`;
359
+ }
360
+ if (summary.updatedAgents.length > 0) {
361
+ content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
362
+ }
363
+ if (summary.deletedAgents.length > 0) {
364
+ content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
365
+ }
366
+ if (summary.errors.length > 0) {
367
+ content += `\n- Errors: ${summary.errors.length} encountered during reload`;
368
+ }
369
+
370
+ content += '\n\nRun /agents list to see all available agents.';
371
+
372
+ return {
373
+ type: 'message',
374
+ messageType: 'info',
375
+ content,
376
+ };
377
+ },
378
+ };
379
+
380
+ export const agentsCommand: SlashCommand = {
381
+ name: 'agents',
382
+ description: 'Manage agents',
383
+ kind: CommandKind.BUILT_IN,
384
+ subCommands: [
385
+ agentsListCommand,
386
+ agentsReloadCommand,
387
+ enableCommand,
388
+ disableCommand,
389
+ configCommand,
390
+ ],
391
+ action: async (context: CommandContext, args) =>
392
+ // Default to list if no subcommand is provided
393
+ agentsListCommand.action!(context, args),
394
+ };
packages/cli/src/ui/commands/authCommand.test.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { authCommand } from './authCommand.js';
9
+ import { type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+ import { SettingScope } from '../../config/settings.js';
12
+ import type { GeminiClient } from '@google/gemini-cli-core';
13
+
14
+ vi.mock('@google/gemini-cli-core', async () => {
15
+ const actual = await vi.importActual('@google/gemini-cli-core');
16
+ return {
17
+ ...actual,
18
+ clearCachedCredentialFile: vi.fn().mockResolvedValue(undefined),
19
+ };
20
+ });
21
+
22
+ describe('authCommand', () => {
23
+ let mockContext: CommandContext;
24
+
25
+ beforeEach(() => {
26
+ mockContext = createMockCommandContext({
27
+ services: {
28
+ agentContext: {
29
+ geminiClient: {
30
+ stripThoughtsFromHistory: vi.fn(),
31
+ },
32
+ },
33
+ },
34
+ });
35
+ // Add setValue mock to settings
36
+ mockContext.services.settings.setValue = vi.fn();
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('should have subcommands: signin and signout', () => {
41
+ expect(authCommand.subCommands).toBeDefined();
42
+ expect(authCommand.subCommands).toHaveLength(2);
43
+ expect(authCommand.subCommands?.[0]?.name).toBe('signin');
44
+ expect(authCommand.subCommands?.[0]?.altNames).toContain('login');
45
+ expect(authCommand.subCommands?.[1]?.name).toBe('signout');
46
+ expect(authCommand.subCommands?.[1]?.altNames).toContain('logout');
47
+ });
48
+
49
+ it('should return a dialog action to open the auth dialog when called with no args', () => {
50
+ if (!authCommand.action) {
51
+ throw new Error('The auth command must have an action.');
52
+ }
53
+
54
+ const result = authCommand.action(mockContext, '');
55
+
56
+ expect(result).toEqual({
57
+ type: 'dialog',
58
+ dialog: 'auth',
59
+ });
60
+ });
61
+
62
+ it('should have the correct name and description', () => {
63
+ expect(authCommand.name).toBe('auth');
64
+ expect(authCommand.description).toBe('Manage authentication');
65
+ });
66
+
67
+ describe('auth signin subcommand', () => {
68
+ it('should return auth dialog action', () => {
69
+ const loginCommand = authCommand.subCommands?.[0];
70
+ expect(loginCommand?.name).toBe('signin');
71
+ const result = loginCommand!.action!(mockContext, '');
72
+ expect(result).toEqual({ type: 'dialog', dialog: 'auth' });
73
+ });
74
+ });
75
+
76
+ describe('auth signout subcommand', () => {
77
+ it('should clear cached credentials', async () => {
78
+ const logoutCommand = authCommand.subCommands?.[1];
79
+ expect(logoutCommand?.name).toBe('signout');
80
+
81
+ const { clearCachedCredentialFile } = await import(
82
+ '@google/gemini-cli-core'
83
+ );
84
+
85
+ await logoutCommand!.action!(mockContext, '');
86
+
87
+ expect(clearCachedCredentialFile).toHaveBeenCalledOnce();
88
+ });
89
+
90
+ it('should clear selectedAuthType setting', async () => {
91
+ const logoutCommand = authCommand.subCommands?.[1];
92
+
93
+ await logoutCommand!.action!(mockContext, '');
94
+
95
+ expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
96
+ SettingScope.User,
97
+ 'security.auth.selectedType',
98
+ undefined,
99
+ );
100
+ });
101
+
102
+ it('should strip thoughts from history', async () => {
103
+ const logoutCommand = authCommand.subCommands?.[1];
104
+ const mockStripThoughts = vi.fn();
105
+ const mockClient = {
106
+ stripThoughtsFromHistory: mockStripThoughts,
107
+ } as unknown as GeminiClient;
108
+ if (mockContext.services.agentContext?.config) {
109
+ mockContext.services.agentContext.config.getGeminiClient = vi.fn(
110
+ () => mockClient,
111
+ );
112
+ }
113
+
114
+ await logoutCommand!.action!(mockContext, '');
115
+
116
+ expect(
117
+ mockContext.services.agentContext?.geminiClient
118
+ .stripThoughtsFromHistory,
119
+ ).toHaveBeenCalled();
120
+ });
121
+
122
+ it('should return logout action to signal explicit state change', async () => {
123
+ const logoutCommand = authCommand.subCommands?.[1];
124
+ const result = await logoutCommand!.action!(mockContext, '');
125
+
126
+ expect(result).toEqual({ type: 'logout' });
127
+ });
128
+
129
+ it('should handle missing config gracefully', async () => {
130
+ const logoutCommand = authCommand.subCommands?.[1];
131
+ mockContext.services.agentContext = null;
132
+
133
+ const result = await logoutCommand!.action!(mockContext, '');
134
+
135
+ expect(result).toEqual({ type: 'logout' });
136
+ });
137
+ });
138
+ });
packages/cli/src/ui/commands/authCommand.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type {
8
+ OpenDialogActionReturn,
9
+ SlashCommand,
10
+ LogoutActionReturn,
11
+ } from './types.js';
12
+ import { CommandKind } from './types.js';
13
+ import { clearCachedCredentialFile } from '@google/gemini-cli-core';
14
+ import { SettingScope } from '../../config/settings.js';
15
+
16
+ const authLoginCommand: SlashCommand = {
17
+ name: 'signin',
18
+ altNames: ['login'],
19
+ description: 'Sign in or change the authentication method',
20
+ kind: CommandKind.BUILT_IN,
21
+ autoExecute: true,
22
+ action: (_context, _args): OpenDialogActionReturn => ({
23
+ type: 'dialog',
24
+ dialog: 'auth',
25
+ }),
26
+ };
27
+
28
+ const authLogoutCommand: SlashCommand = {
29
+ name: 'signout',
30
+ altNames: ['logout'],
31
+ description: 'Sign out and clear all cached credentials',
32
+ kind: CommandKind.BUILT_IN,
33
+ action: async (context, _args): Promise<LogoutActionReturn> => {
34
+ await clearCachedCredentialFile();
35
+ // Clear the selected auth type so user sees the auth selection menu
36
+ context.services.settings.setValue(
37
+ SettingScope.User,
38
+ 'security.auth.selectedType',
39
+ undefined,
40
+ );
41
+ // Strip thoughts from history instead of clearing completely
42
+ context.services.agentContext?.geminiClient.stripThoughtsFromHistory();
43
+ // Return logout action to signal explicit state change
44
+ return {
45
+ type: 'logout',
46
+ };
47
+ },
48
+ };
49
+
50
+ export const authCommand: SlashCommand = {
51
+ name: 'auth',
52
+ description: 'Manage authentication',
53
+ kind: CommandKind.BUILT_IN,
54
+ subCommands: [authLoginCommand, authLogoutCommand],
55
+ action: (context, args) =>
56
+ // Default to login if no subcommand is provided
57
+ authLoginCommand.action!(context, args),
58
+ };
packages/cli/src/ui/commands/bugCommand.test.ts ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import open from 'open';
9
+ import path from 'node:path';
10
+ import { bugCommand } from './bugCommand.js';
11
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
12
+ import { getVersion, type Config } from '@google/gemini-cli-core';
13
+ import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
14
+ import { formatBytes } from '../utils/formatters.js';
15
+ import { MessageType } from '../types.js';
16
+ import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
17
+
18
+ const { memoryUsageMock } = vi.hoisted(() => ({
19
+ memoryUsageMock: vi.fn(() => ({
20
+ rss: 0,
21
+ heapTotal: 0,
22
+ heapUsed: 0,
23
+ external: 0,
24
+ arrayBuffers: 0,
25
+ })),
26
+ }));
27
+
28
+ // Mock dependencies
29
+ vi.mock('open');
30
+ vi.mock('../utils/formatters.js');
31
+ vi.mock('../utils/memorySnapshot.js', () => ({
32
+ captureHeapSnapshot: vi.fn(),
33
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
34
+ }));
35
+ vi.mock('node:fs/promises', async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
37
+ return {
38
+ ...actual,
39
+ stat: vi.fn().mockResolvedValue({ size: 4096 }),
40
+ };
41
+ });
42
+ vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
43
+ const actual =
44
+ await importOriginal<typeof import('../utils/historyExportUtils.js')>();
45
+ return {
46
+ ...actual,
47
+ exportHistoryToFile: vi.fn(),
48
+ };
49
+ });
50
+ import { exportHistoryToFile } from '../utils/historyExportUtils.js';
51
+
52
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
53
+ const actual =
54
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
55
+ return {
56
+ ...actual,
57
+ IdeClient: {
58
+ getInstance: () => ({
59
+ getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
60
+ }),
61
+ },
62
+ sessionId: 'test-session-id',
63
+ getVersion: vi.fn(),
64
+ INITIAL_HISTORY_LENGTH: 1,
65
+ debugLogger: {
66
+ error: vi.fn(),
67
+ log: vi.fn(),
68
+ debug: vi.fn(),
69
+ warn: vi.fn(),
70
+ },
71
+ };
72
+ });
73
+ vi.mock('node:process', () => ({
74
+ default: {
75
+ platform: 'test-platform',
76
+ version: 'v20.0.0',
77
+ // Keep other necessary process properties if needed by other parts of the code
78
+ env: process.env,
79
+ memoryUsage: memoryUsageMock,
80
+ },
81
+ }));
82
+
83
+ vi.mock('../utils/terminalCapabilityManager.js', () => ({
84
+ terminalCapabilityManager: {
85
+ getTerminalName: vi.fn().mockReturnValue('Test Terminal'),
86
+ getTerminalBackgroundColor: vi.fn().mockReturnValue('#000000'),
87
+ isKittyProtocolEnabled: vi.fn().mockReturnValue(true),
88
+ },
89
+ }));
90
+
91
+ describe('bugCommand', () => {
92
+ beforeEach(() => {
93
+ vi.mocked(getVersion).mockResolvedValue('0.1.0');
94
+ vi.mocked(formatBytes).mockReturnValue('100 MB');
95
+ memoryUsageMock.mockReturnValue({
96
+ rss: 0,
97
+ heapTotal: 0,
98
+ heapUsed: 0,
99
+ external: 0,
100
+ arrayBuffers: 0,
101
+ });
102
+ vi.stubEnv('SANDBOX', 'gemini-test');
103
+ vi.useFakeTimers();
104
+ vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
105
+ });
106
+
107
+ afterEach(() => {
108
+ vi.unstubAllEnvs();
109
+ vi.clearAllMocks();
110
+ vi.useRealTimers();
111
+ });
112
+
113
+ it('should generate the default GitHub issue URL', async () => {
114
+ const mockContext = createMockCommandContext({
115
+ services: {
116
+ agentContext: {
117
+ config: {
118
+ getModel: () => 'gemini-pro',
119
+ getBugCommand: () => undefined,
120
+ getIdeMode: () => true,
121
+ getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
122
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
123
+ } as unknown as Config,
124
+ geminiClient: {
125
+ getChat: () => ({
126
+ getHistory: () => [],
127
+ }),
128
+ },
129
+ },
130
+ },
131
+ });
132
+
133
+ if (!bugCommand.action) throw new Error('Action is not defined');
134
+ await bugCommand.action(mockContext, 'A test bug');
135
+
136
+ const expectedInfo = `
137
+ * **CLI Version:** 0.1.0
138
+ * **Git Commit:** ${GIT_COMMIT_INFO}
139
+ * **Session ID:** test-session-id
140
+ * **Operating System:** test-platform v20.0.0
141
+ * **Sandbox Environment:** test
142
+ * **Model Version:** gemini-pro
143
+ * **Auth Type:** oauth-personal
144
+ * **Memory Usage:** 100 MB
145
+ * **Terminal Name:** Test Terminal
146
+ * **Terminal Background:** #000000
147
+ * **Kitty Keyboard Protocol:** Supported
148
+ * **IDE Client:** VSCode
149
+ `;
150
+ const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`;
151
+
152
+ expect(open).toHaveBeenCalledWith(expectedUrl);
153
+ });
154
+
155
+ it('should export chat history if available', async () => {
156
+ const history = [
157
+ { role: 'user', parts: [{ text: 'hello' }] },
158
+ { role: 'model', parts: [{ text: 'hi' }] },
159
+ ];
160
+ const mockContext = createMockCommandContext({
161
+ services: {
162
+ agentContext: {
163
+ config: {
164
+ getModel: () => 'gemini-pro',
165
+ getBugCommand: () => undefined,
166
+ getIdeMode: () => true,
167
+ getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
168
+ storage: {
169
+ getProjectTempDir: () => '/tmp/gemini',
170
+ },
171
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
172
+ } as unknown as Config,
173
+ geminiClient: {
174
+ getChat: () => ({
175
+ getHistory: () => history,
176
+ }),
177
+ },
178
+ },
179
+ },
180
+ });
181
+
182
+ if (!bugCommand.action) throw new Error('Action is not defined');
183
+ await bugCommand.action(mockContext, 'Bug with history');
184
+
185
+ const expectedPath = path.join(
186
+ '/tmp/gemini',
187
+ 'bug-report-history-1704067200000.json',
188
+ );
189
+ expect(exportHistoryToFile).toHaveBeenCalledWith({
190
+ history,
191
+ filePath: expectedPath,
192
+ });
193
+
194
+ const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
195
+ const messageText = addItemCall[0].text;
196
+ expect(messageText).toContain(expectedPath);
197
+ expect(messageText).toContain('📄 **Chat History Exported**');
198
+ expect(messageText).toContain('Privacy Disclaimer:');
199
+ expect(messageText).not.toContain('additional-context=');
200
+ expect(messageText).toContain('problem=');
201
+ const reminder =
202
+ '\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.';
203
+ expect(messageText).toContain(encodeURIComponent(reminder));
204
+ });
205
+
206
+ it('should use a custom URL template from config if provided', async () => {
207
+ const customTemplate =
208
+ 'https://internal.bug-tracker.com/new?desc={title}&details={info}';
209
+ const mockContext = createMockCommandContext({
210
+ services: {
211
+ agentContext: {
212
+ config: {
213
+ getModel: () => 'gemini-pro',
214
+ getBugCommand: () => ({ urlTemplate: customTemplate }),
215
+ getIdeMode: () => true,
216
+ getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
217
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
218
+ } as unknown as Config,
219
+ geminiClient: {
220
+ getChat: () => ({
221
+ getHistory: () => [],
222
+ }),
223
+ },
224
+ },
225
+ },
226
+ });
227
+
228
+ if (!bugCommand.action) throw new Error('Action is not defined');
229
+ await bugCommand.action(mockContext, 'A custom bug');
230
+
231
+ const expectedInfo = `
232
+ * **CLI Version:** 0.1.0
233
+ * **Git Commit:** ${GIT_COMMIT_INFO}
234
+ * **Session ID:** test-session-id
235
+ * **Operating System:** test-platform v20.0.0
236
+ * **Sandbox Environment:** test
237
+ * **Model Version:** gemini-pro
238
+ * **Auth Type:** vertex-ai
239
+ * **Memory Usage:** 100 MB
240
+ * **Terminal Name:** Test Terminal
241
+ * **Terminal Background:** #000000
242
+ * **Kitty Keyboard Protocol:** Supported
243
+ * **IDE Client:** VSCode
244
+ `;
245
+ const expectedUrl = customTemplate
246
+ .replace('{title}', encodeURIComponent('A custom bug'))
247
+ .replace('{info}', encodeURIComponent(expectedInfo));
248
+
249
+ expect(open).toHaveBeenCalledWith(expectedUrl);
250
+ });
251
+
252
+ const buildHighMemoryContext = (tempDir: string | undefined) =>
253
+ createMockCommandContext({
254
+ services: {
255
+ agentContext: {
256
+ config: {
257
+ getModel: () => 'gemini-pro',
258
+ getBugCommand: () => undefined,
259
+ getIdeMode: () => false,
260
+ getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
261
+ storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
262
+ getSessionId: vi.fn().mockReturnValue('test-session-id'),
263
+ } as unknown as Config,
264
+ geminiClient: { getChat: () => ({ getHistory: () => [] }) },
265
+ },
266
+ },
267
+ });
268
+
269
+ it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
270
+ memoryUsageMock.mockReturnValue({
271
+ rss: 3 * 1024 * 1024 * 1024,
272
+ heapTotal: 0,
273
+ heapUsed: 0,
274
+ external: 0,
275
+ arrayBuffers: 0,
276
+ });
277
+ vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
278
+
279
+ const tempDir = path.join('/tmp', 'gemini-test');
280
+ const context = buildHighMemoryContext(tempDir);
281
+
282
+ if (!bugCommand.action) throw new Error('Action is not defined');
283
+ await bugCommand.action(context, 'A memory bug');
284
+
285
+ const now = new Date('2024-01-01T00:00:00Z').getTime();
286
+ const expectedSnapshotPath = path.join(
287
+ tempDir,
288
+ `bug-memory-${now}.heapsnapshot`,
289
+ );
290
+ expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
291
+
292
+ const addItem = vi.mocked(context.ui.addItem);
293
+ const callOrder = addItem.mock.invocationCallOrder;
294
+ const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
295
+ // The URL message must precede the "capturing" message so the user sees
296
+ // the URL before the 20+ second snapshot starts.
297
+ expect(callOrder[0]).toBeLessThan(openOrder);
298
+ expect(callOrder[1]).toBeGreaterThan(openOrder);
299
+ expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
300
+ expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
301
+ expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
302
+ expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
303
+ });
304
+
305
+ it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
306
+ memoryUsageMock.mockReturnValue({
307
+ rss: 1 * 1024 * 1024 * 1024,
308
+ heapTotal: 0,
309
+ heapUsed: 0,
310
+ external: 0,
311
+ arrayBuffers: 0,
312
+ });
313
+ const context = buildHighMemoryContext('/tmp/gemini-test');
314
+
315
+ if (!bugCommand.action) throw new Error('Action is not defined');
316
+ await bugCommand.action(context, 'A light bug');
317
+
318
+ expect(captureHeapSnapshot).not.toHaveBeenCalled();
319
+ });
320
+
321
+ it('reports an error if the auto-capture fails but does not throw', async () => {
322
+ memoryUsageMock.mockReturnValue({
323
+ rss: 3 * 1024 * 1024 * 1024,
324
+ heapTotal: 0,
325
+ heapUsed: 0,
326
+ external: 0,
327
+ arrayBuffers: 0,
328
+ });
329
+ vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
330
+ new Error('inspector failure'),
331
+ );
332
+ const context = buildHighMemoryContext('/tmp/gemini-test');
333
+
334
+ if (!bugCommand.action) throw new Error('Action is not defined');
335
+ await expect(
336
+ bugCommand.action(context, 'A memory bug'),
337
+ ).resolves.toBeUndefined();
338
+
339
+ const addItem = vi.mocked(context.ui.addItem).mock.calls;
340
+ const lastCall = addItem[addItem.length - 1][0];
341
+ expect(lastCall.type).toBe(MessageType.ERROR);
342
+ expect(lastCall.text).toContain('inspector failure');
343
+ });
344
+ });
packages/cli/src/ui/commands/bugCommand.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import open from 'open';
8
+ import process from 'node:process';
9
+ import {
10
+ type CommandContext,
11
+ type SlashCommand,
12
+ CommandKind,
13
+ } from './types.js';
14
+ import { MessageType } from '../types.js';
15
+ import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
16
+ import { formatBytes } from '../utils/formatters.js';
17
+ import {
18
+ IdeClient,
19
+ getVersion,
20
+ INITIAL_HISTORY_LENGTH,
21
+ debugLogger,
22
+ } from '@google/gemini-cli-core';
23
+ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
24
+ import { exportHistoryToFile } from '../utils/historyExportUtils.js';
25
+ import {
26
+ captureHeapSnapshot,
27
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
28
+ } from '../utils/memorySnapshot.js';
29
+ import { stat } from 'node:fs/promises';
30
+ import path from 'node:path';
31
+
32
+ export const bugCommand: SlashCommand = {
33
+ name: 'bug',
34
+ description: 'Submit a bug report',
35
+ kind: CommandKind.BUILT_IN,
36
+ autoExecute: false,
37
+ action: async (context: CommandContext, args?: string): Promise<void> => {
38
+ const bugDescription = (args || '').trim();
39
+ const agentContext = context.services.agentContext;
40
+ const config = agentContext?.config;
41
+ const osVersion = `${process.platform} ${process.version}`;
42
+ let sandboxEnv = 'no sandbox';
43
+ if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
44
+ sandboxEnv = process.env['SANDBOX'].replace(/^gemini-(?:code-)?/, '');
45
+ } else if (process.env['SANDBOX'] === 'sandbox-exec') {
46
+ sandboxEnv = `sandbox-exec (${
47
+ process.env['SEATBELT_PROFILE'] || 'unknown'
48
+ })`;
49
+ }
50
+ const modelVersion = config?.getModel() || 'Unknown';
51
+ const cliVersion = await getVersion();
52
+ const memoryUsage = formatBytes(process.memoryUsage().rss);
53
+ const ideClient = await getIdeClientName(context);
54
+ const terminalName =
55
+ terminalCapabilityManager.getTerminalName() || 'Unknown';
56
+ const terminalBgColor =
57
+ terminalCapabilityManager.getTerminalBackgroundColor() || 'Unknown';
58
+ const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled()
59
+ ? 'Supported'
60
+ : 'Unsupported';
61
+ const authType = config?.getContentGeneratorConfig()?.authType || 'Unknown';
62
+
63
+ let info = `
64
+ * **CLI Version:** ${cliVersion}
65
+ * **Git Commit:** ${GIT_COMMIT_INFO}
66
+ * **Session ID:** ${config?.getSessionId() || 'Unknown'}
67
+ * **Operating System:** ${osVersion}
68
+ * **Sandbox Environment:** ${sandboxEnv}
69
+ * **Model Version:** ${modelVersion}
70
+ * **Auth Type:** ${authType}
71
+ * **Memory Usage:** ${memoryUsage}
72
+ * **Terminal Name:** ${terminalName}
73
+ * **Terminal Background:** ${terminalBgColor}
74
+ * **Kitty Keyboard Protocol:** ${kittyProtocol}
75
+ `;
76
+ if (ideClient) {
77
+ info += `* **IDE Client:** ${ideClient}\n`;
78
+ }
79
+
80
+ const chat = agentContext?.geminiClient?.getChat();
81
+ const history = chat?.getHistory() || [];
82
+ let historyFileMessage = '';
83
+ let problemValue = bugDescription;
84
+
85
+ if (history.length > INITIAL_HISTORY_LENGTH) {
86
+ const tempDir = config?.storage?.getProjectTempDir();
87
+ if (tempDir) {
88
+ const historyFileName = `bug-report-history-${Date.now()}.json`;
89
+ const historyFilePath = path.join(tempDir, historyFileName);
90
+ try {
91
+ await exportHistoryToFile({ history, filePath: historyFilePath });
92
+ historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`;
93
+ problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`;
94
+ } catch (err) {
95
+ const errorMessage = err instanceof Error ? err.message : String(err);
96
+ debugLogger.error(
97
+ `Failed to export chat history for bug report: ${errorMessage}`,
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ let bugReportUrl =
104
+ 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}';
105
+
106
+ const bugCommandSettings = config?.getBugCommand();
107
+ if (bugCommandSettings?.urlTemplate) {
108
+ bugReportUrl = bugCommandSettings.urlTemplate;
109
+ }
110
+
111
+ bugReportUrl = bugReportUrl
112
+ .replace('{title}', encodeURIComponent(bugDescription))
113
+ .replace('{info}', encodeURIComponent(info))
114
+ .replace('{problem}', encodeURIComponent(problemValue));
115
+
116
+ context.ui.addItem(
117
+ {
118
+ type: MessageType.INFO,
119
+ text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`,
120
+ },
121
+ Date.now(),
122
+ );
123
+
124
+ try {
125
+ await open(bugReportUrl);
126
+ } catch (error) {
127
+ const errorMessage =
128
+ error instanceof Error ? error.message : String(error);
129
+ context.ui.addItem(
130
+ {
131
+ type: MessageType.ERROR,
132
+ text: `Could not open URL in browser: ${errorMessage}`,
133
+ },
134
+ Date.now(),
135
+ );
136
+ }
137
+
138
+ const rss = process.memoryUsage().rss;
139
+ const tempDir = config?.storage?.getProjectTempDir();
140
+ if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
141
+ const snapshotPath = path.join(
142
+ tempDir,
143
+ `bug-memory-${Date.now()}.heapsnapshot`,
144
+ );
145
+ context.ui.addItem(
146
+ {
147
+ type: MessageType.INFO,
148
+ text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
149
+ },
150
+ Date.now(),
151
+ );
152
+ try {
153
+ const startedAt = Date.now();
154
+ await captureHeapSnapshot(snapshotPath);
155
+ const durationMs = Date.now() - startedAt;
156
+ let sizeText = '';
157
+ try {
158
+ const { size } = await stat(snapshotPath);
159
+ sizeText = ` (${formatBytes(size)})`;
160
+ } catch {
161
+ // Size reporting is best-effort; the snapshot itself was captured successfully.
162
+ }
163
+ context.ui.addItem(
164
+ {
165
+ type: MessageType.INFO,
166
+ text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
167
+ },
168
+ Date.now(),
169
+ );
170
+ } catch (error) {
171
+ const errorMessage =
172
+ error instanceof Error ? error.message : String(error);
173
+ debugLogger.error(
174
+ `Failed to capture heap snapshot for bug report: ${errorMessage}`,
175
+ );
176
+ context.ui.addItem(
177
+ {
178
+ type: MessageType.ERROR,
179
+ text: `Failed to capture heap snapshot: ${errorMessage}`,
180
+ },
181
+ Date.now(),
182
+ );
183
+ }
184
+ }
185
+ },
186
+ };
187
+
188
+ async function getIdeClientName(context: CommandContext) {
189
+ if (!context.services.agentContext?.config.getIdeMode()) {
190
+ return '';
191
+ }
192
+ const ideClient = await IdeClient.getInstance();
193
+ return ideClient.getDetectedIdeDisplayName() ?? '';
194
+ }
packages/cli/src/ui/commands/bugMemoryCommand.test.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import path from 'node:path';
9
+ import { bugMemoryCommand } from './bugMemoryCommand.js';
10
+ import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
11
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
12
+ import { MessageType } from '../types.js';
13
+ import type { Config } from '@google/gemini-cli-core';
14
+
15
+ vi.mock('../utils/memorySnapshot.js', () => ({
16
+ captureHeapSnapshot: vi.fn(),
17
+ MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
18
+ }));
19
+
20
+ vi.mock('node:fs/promises', async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
22
+ return {
23
+ ...actual,
24
+ stat: vi.fn().mockResolvedValue({ size: 1234 }),
25
+ };
26
+ });
27
+
28
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
29
+ const actual =
30
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
31
+ return {
32
+ ...actual,
33
+ debugLogger: {
34
+ error: vi.fn(),
35
+ log: vi.fn(),
36
+ debug: vi.fn(),
37
+ warn: vi.fn(),
38
+ },
39
+ };
40
+ });
41
+
42
+ function makeContextWithTempDir(tempDir: string | undefined) {
43
+ return createMockCommandContext({
44
+ services: {
45
+ agentContext: {
46
+ config: {
47
+ storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
48
+ } as unknown as Config,
49
+ },
50
+ },
51
+ });
52
+ }
53
+
54
+ describe('bugMemoryCommand', () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.clearAllMocks();
62
+ vi.useRealTimers();
63
+ });
64
+
65
+ it('declares itself as a non-auto-executing built-in command', () => {
66
+ expect(bugMemoryCommand.name).toBe('bug-memory');
67
+ expect(bugMemoryCommand.autoExecute).toBe(false);
68
+ expect(bugMemoryCommand.description).toBeTruthy();
69
+ });
70
+
71
+ it('captures a heap snapshot and reports the file path', async () => {
72
+ const tempDir = path.join('/tmp', 'gemini-test');
73
+ const context = makeContextWithTempDir(tempDir);
74
+ vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
75
+
76
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
77
+ await bugMemoryCommand.action(context, '');
78
+
79
+ const expectedPath = path.join(
80
+ tempDir,
81
+ `bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
82
+ );
83
+ expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
84
+
85
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
86
+ expect(addItemCalls).toHaveLength(2);
87
+ expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
88
+ expect(addItemCalls[0][0].text).toContain(expectedPath);
89
+ expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
90
+ expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
91
+ expect(addItemCalls[1][0].text).toContain(expectedPath);
92
+ });
93
+
94
+ it('surfaces an error if capture fails', async () => {
95
+ const context = makeContextWithTempDir('/tmp/gemini-test');
96
+ vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
97
+ new Error('inspector disconnected'),
98
+ );
99
+
100
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
101
+ await bugMemoryCommand.action(context, '');
102
+
103
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
104
+ const lastCall = addItemCalls[addItemCalls.length - 1][0];
105
+ expect(lastCall.type).toBe(MessageType.ERROR);
106
+ expect(lastCall.text).toContain('inspector disconnected');
107
+ });
108
+
109
+ it('emits an error when no project temp directory is available', async () => {
110
+ const context = makeContextWithTempDir(undefined);
111
+
112
+ if (!bugMemoryCommand.action) throw new Error('Action missing');
113
+ await bugMemoryCommand.action(context, '');
114
+
115
+ expect(captureHeapSnapshot).not.toHaveBeenCalled();
116
+ const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
117
+ expect(addItemCalls).toHaveLength(1);
118
+ expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
119
+ expect(addItemCalls[0][0].text).toContain('temp directory');
120
+ });
121
+ });
packages/cli/src/ui/commands/bugMemoryCommand.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { stat } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { debugLogger } from '@google/gemini-cli-core';
11
+ import {
12
+ type CommandContext,
13
+ type SlashCommand,
14
+ CommandKind,
15
+ } from './types.js';
16
+ import { MessageType } from '../types.js';
17
+ import { formatBytes } from '../utils/formatters.js';
18
+ import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
19
+
20
+ export const bugMemoryCommand: SlashCommand = {
21
+ name: 'bug-memory',
22
+ description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
23
+ kind: CommandKind.BUILT_IN,
24
+ autoExecute: false,
25
+ action: async (context: CommandContext): Promise<void> => {
26
+ const tempDir =
27
+ context.services.agentContext?.config?.storage?.getProjectTempDir();
28
+ if (!tempDir) {
29
+ context.ui.addItem(
30
+ {
31
+ type: MessageType.ERROR,
32
+ text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
33
+ },
34
+ Date.now(),
35
+ );
36
+ return;
37
+ }
38
+
39
+ const filePath = path.join(
40
+ tempDir,
41
+ `bug-memory-${Date.now()}.heapsnapshot`,
42
+ );
43
+ const rss = process.memoryUsage().rss;
44
+
45
+ context.ui.addItem(
46
+ {
47
+ type: MessageType.INFO,
48
+ text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
49
+ },
50
+ Date.now(),
51
+ );
52
+
53
+ const startedAt = Date.now();
54
+ try {
55
+ await captureHeapSnapshot(filePath);
56
+ } catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ debugLogger.error(`Failed to capture heap snapshot: ${message}`);
59
+ context.ui.addItem(
60
+ {
61
+ type: MessageType.ERROR,
62
+ text: `Failed to capture heap snapshot: ${message}`,
63
+ },
64
+ Date.now(),
65
+ );
66
+ return;
67
+ }
68
+
69
+ const durationMs = Date.now() - startedAt;
70
+ let sizeText = '';
71
+ try {
72
+ const { size } = await stat(filePath);
73
+ sizeText = ` (${formatBytes(size)})`;
74
+ } catch {
75
+ // Size reporting is best-effort; the snapshot itself was captured successfully.
76
+ }
77
+
78
+ context.ui.addItem(
79
+ {
80
+ type: MessageType.INFO,
81
+ text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
82
+ },
83
+ Date.now(),
84
+ );
85
+ },
86
+ };
packages/cli/src/ui/commands/chatCommand.test.ts ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+
9
+ import type { SlashCommand, CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+ import type { Content } from '@google/genai';
12
+ import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
13
+
14
+ import * as fsPromises from 'node:fs/promises';
15
+ import { chatCommand, debugCommand } from './chatCommand.js';
16
+ import {
17
+ serializeHistoryToMarkdown,
18
+ exportHistoryToFile,
19
+ } from '../utils/historyExportUtils.js';
20
+ import type { Stats } from 'node:fs';
21
+ import type { HistoryItemWithoutId } from '../types.js';
22
+ import path from 'node:path';
23
+
24
+ vi.mock('fs/promises', () => ({
25
+ stat: vi.fn(),
26
+ readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
27
+ writeFile: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
31
+ const actual =
32
+ await importOriginal<typeof import('../utils/historyExportUtils.js')>();
33
+ return {
34
+ ...actual,
35
+ exportHistoryToFile: vi.fn(),
36
+ };
37
+ });
38
+
39
+ describe('chatCommand', () => {
40
+ const mockFs = vi.mocked(fsPromises);
41
+ const mockExport = vi.mocked(exportHistoryToFile);
42
+
43
+ let mockContext: CommandContext;
44
+ let mockGetChat: ReturnType<typeof vi.fn>;
45
+ let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
46
+ let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
47
+ let mockDeleteCheckpoint: ReturnType<typeof vi.fn>;
48
+ let mockGetHistory: ReturnType<typeof vi.fn>;
49
+
50
+ const getSubCommand = (
51
+ name: 'list' | 'save' | 'resume' | 'delete' | 'share',
52
+ ): SlashCommand => {
53
+ const subCommand = chatCommand.subCommands?.find(
54
+ (cmd) => cmd.name === name,
55
+ );
56
+ if (!subCommand) {
57
+ throw new Error(`/chat ${name} command not found.`);
58
+ }
59
+ return subCommand;
60
+ };
61
+
62
+ beforeEach(() => {
63
+ mockGetHistory = vi.fn().mockReturnValue([]);
64
+ mockGetChat = vi.fn().mockReturnValue({
65
+ getHistory: mockGetHistory,
66
+ });
67
+ mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
68
+ mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
69
+ mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
70
+
71
+ mockContext = createMockCommandContext({
72
+ services: {
73
+ agentContext: {
74
+ config: {
75
+ getProjectRoot: () => '/project/root',
76
+ getContentGeneratorConfig: () => ({
77
+ authType: AuthType.LOGIN_WITH_GOOGLE,
78
+ }),
79
+ storage: {
80
+ getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
81
+ },
82
+ },
83
+ geminiClient: {
84
+ getChat: mockGetChat,
85
+ } as unknown as GeminiClient,
86
+ },
87
+ logger: {
88
+ saveCheckpoint: mockSaveCheckpoint,
89
+ loadCheckpoint: mockLoadCheckpoint,
90
+ deleteCheckpoint: mockDeleteCheckpoint,
91
+ initialize: vi.fn().mockResolvedValue(undefined),
92
+ },
93
+ },
94
+ });
95
+ });
96
+
97
+ afterEach(() => {
98
+ vi.restoreAllMocks();
99
+ });
100
+
101
+ it('should have the correct main command definition', () => {
102
+ expect(chatCommand.name).toBe('chat');
103
+ expect(chatCommand.description).toBe(
104
+ 'Browse auto-saved conversations and manage chat checkpoints',
105
+ );
106
+ expect(chatCommand.autoExecute).toBe(true);
107
+ expect(chatCommand.subCommands).toHaveLength(6);
108
+ });
109
+
110
+ describe('list subcommand', () => {
111
+ let listCommand: SlashCommand;
112
+
113
+ beforeEach(() => {
114
+ listCommand = getSubCommand('list');
115
+ });
116
+
117
+ it('should add a chat_list item to the UI', async () => {
118
+ const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
119
+ const date1 = new Date();
120
+ const date2 = new Date(date1.getTime() + 1000);
121
+
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ mockFs.readdir.mockResolvedValue(fakeFiles as any);
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ mockFs.stat.mockImplementation(async (path: any): Promise<Stats> => {
126
+ if (path.endsWith('test1.json')) {
127
+ return { mtime: date1 } as Stats;
128
+ }
129
+ return { mtime: date2 } as Stats;
130
+ });
131
+
132
+ await listCommand?.action?.(mockContext, '');
133
+
134
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
135
+ type: 'chat_list',
136
+ chats: [
137
+ {
138
+ name: 'test1',
139
+ mtime: date1.toISOString(),
140
+ },
141
+ {
142
+ name: 'test2',
143
+ mtime: date2.toISOString(),
144
+ },
145
+ ],
146
+ });
147
+ });
148
+ });
149
+ describe('save subcommand', () => {
150
+ let saveCommand: SlashCommand;
151
+ const tag = 'my-tag';
152
+ let mockCheckpointExists: ReturnType<typeof vi.fn>;
153
+
154
+ beforeEach(() => {
155
+ saveCommand = getSubCommand('save');
156
+ mockCheckpointExists = vi.fn().mockResolvedValue(false);
157
+ mockContext.services.logger.checkpointExists = mockCheckpointExists;
158
+ });
159
+
160
+ it('should return an error if tag is missing', async () => {
161
+ const result = await saveCommand?.action?.(mockContext, ' ');
162
+ expect(result).toEqual({
163
+ type: 'message',
164
+ messageType: 'error',
165
+ content: 'Missing tag. Usage: /resume save <tag>',
166
+ });
167
+ });
168
+
169
+ it('should inform if conversation history is empty or only contains system context', async () => {
170
+ mockGetHistory.mockReturnValue([]);
171
+ let result = await saveCommand?.action?.(mockContext, tag);
172
+ expect(result).toEqual({
173
+ type: 'message',
174
+ messageType: 'info',
175
+ content: 'No conversation found to save.',
176
+ });
177
+
178
+ mockGetHistory.mockReturnValue([
179
+ { role: 'user', parts: [{ text: 'context for our chat' }] },
180
+ ]);
181
+ result = await saveCommand?.action?.(mockContext, tag);
182
+ expect(result).toEqual({
183
+ type: 'message',
184
+ messageType: 'info',
185
+ content: 'No conversation found to save.',
186
+ });
187
+
188
+ mockGetHistory.mockReturnValue([
189
+ { role: 'user', parts: [{ text: 'context for our chat' }] },
190
+ { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
191
+ { role: 'user', parts: [{ text: 'Hello, how are you?' }] },
192
+ ]);
193
+ result = await saveCommand?.action?.(mockContext, tag);
194
+ expect(result).toEqual({
195
+ type: 'message',
196
+ messageType: 'info',
197
+ content: `Conversation checkpoint saved with tag: ${tag}.`,
198
+ });
199
+ });
200
+
201
+ it('should return confirm_action if checkpoint already exists', async () => {
202
+ mockCheckpointExists.mockResolvedValue(true);
203
+ mockContext.invocation = {
204
+ raw: `/chat save ${tag}`,
205
+ name: 'save',
206
+ args: tag,
207
+ };
208
+
209
+ const result = await saveCommand?.action?.(mockContext, tag);
210
+
211
+ expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
212
+ expect(mockSaveCheckpoint).not.toHaveBeenCalled();
213
+ expect(result).toMatchObject({
214
+ type: 'confirm_action',
215
+ originalInvocation: { raw: `/chat save ${tag}` },
216
+ });
217
+ // Check that prompt is a React element
218
+ expect(result).toHaveProperty('prompt');
219
+ });
220
+
221
+ it('should save the conversation if overwrite is confirmed', async () => {
222
+ const history: Content[] = [
223
+ { role: 'user', parts: [{ text: 'context for our chat' }] },
224
+ { role: 'user', parts: [{ text: 'hello' }] },
225
+ ];
226
+ mockGetHistory.mockReturnValue(history);
227
+ mockContext.overwriteConfirmed = true;
228
+
229
+ const result = await saveCommand?.action?.(mockContext, tag);
230
+
231
+ expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
232
+ expect(mockSaveCheckpoint).toHaveBeenCalledWith(
233
+ { history, authType: AuthType.LOGIN_WITH_GOOGLE },
234
+ tag,
235
+ );
236
+ expect(result).toEqual({
237
+ type: 'message',
238
+ messageType: 'info',
239
+ content: `Conversation checkpoint saved with tag: ${tag}.`,
240
+ });
241
+ });
242
+ });
243
+
244
+ describe('resume subcommand', () => {
245
+ const goodTag = 'good-tag';
246
+ const badTag = 'bad-tag';
247
+
248
+ let resumeCommand: SlashCommand;
249
+ beforeEach(() => {
250
+ resumeCommand = getSubCommand('resume');
251
+ });
252
+
253
+ it('should return an error if tag is missing', async () => {
254
+ const result = await resumeCommand?.action?.(mockContext, '');
255
+
256
+ expect(result).toEqual({
257
+ type: 'message',
258
+ messageType: 'error',
259
+ content: 'Missing tag. Usage: /resume resume <tag>',
260
+ });
261
+ });
262
+
263
+ it('should inform if checkpoint is not found', async () => {
264
+ mockLoadCheckpoint.mockResolvedValue({ history: [] });
265
+
266
+ const result = await resumeCommand?.action?.(mockContext, badTag);
267
+
268
+ expect(result).toEqual({
269
+ type: 'message',
270
+ messageType: 'info',
271
+ content: `No saved checkpoint found with tag: ${badTag}.`,
272
+ });
273
+ });
274
+
275
+ it('should resume a conversation with matching authType', async () => {
276
+ const conversation: Content[] = [
277
+ { role: 'user', parts: [{ text: 'system setup' }] },
278
+ { role: 'user', parts: [{ text: 'hello gemini' }] },
279
+ { role: 'model', parts: [{ text: 'hello world' }] },
280
+ ];
281
+ mockLoadCheckpoint.mockResolvedValue({
282
+ history: conversation,
283
+ authType: AuthType.LOGIN_WITH_GOOGLE,
284
+ });
285
+
286
+ const result = await resumeCommand?.action?.(mockContext, goodTag);
287
+
288
+ expect(result).toEqual({
289
+ type: 'load_history',
290
+ history: [
291
+ { type: 'user', text: 'hello gemini' },
292
+ { type: 'gemini', text: 'hello world' },
293
+ ] as HistoryItemWithoutId[],
294
+ clientHistory: conversation,
295
+ });
296
+ });
297
+
298
+ it('should block resuming a conversation with mismatched authType', async () => {
299
+ const conversation: Content[] = [
300
+ { role: 'user', parts: [{ text: 'system setup' }] },
301
+ { role: 'user', parts: [{ text: 'hello gemini' }] },
302
+ { role: 'model', parts: [{ text: 'hello world' }] },
303
+ ];
304
+ mockLoadCheckpoint.mockResolvedValue({
305
+ history: conversation,
306
+ authType: AuthType.USE_GEMINI,
307
+ });
308
+
309
+ const result = await resumeCommand?.action?.(mockContext, goodTag);
310
+
311
+ expect(result).toEqual({
312
+ type: 'message',
313
+ messageType: 'error',
314
+ content: `Cannot resume chat. It was saved with a different authentication method (${AuthType.USE_GEMINI}) than the current one (${AuthType.LOGIN_WITH_GOOGLE}).`,
315
+ });
316
+ });
317
+
318
+ it('should resume a legacy conversation without authType', async () => {
319
+ const conversation: Content[] = [
320
+ { role: 'user', parts: [{ text: 'system setup' }] },
321
+ { role: 'user', parts: [{ text: 'hello gemini' }] },
322
+ { role: 'model', parts: [{ text: 'hello world' }] },
323
+ ];
324
+ mockLoadCheckpoint.mockResolvedValue({ history: conversation });
325
+
326
+ const result = await resumeCommand?.action?.(mockContext, goodTag);
327
+
328
+ expect(result).toEqual({
329
+ type: 'load_history',
330
+ history: [
331
+ { type: 'user', text: 'hello gemini' },
332
+ { type: 'gemini', text: 'hello world' },
333
+ ] as HistoryItemWithoutId[],
334
+ clientHistory: conversation,
335
+ });
336
+ });
337
+
338
+ describe('completion', () => {
339
+ it('should provide completion suggestions', async () => {
340
+ const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
341
+ mockFs.readdir.mockImplementation(
342
+ (async (_: string): Promise<string[]> =>
343
+ fakeFiles) as unknown as typeof fsPromises.readdir,
344
+ );
345
+
346
+ mockFs.stat.mockImplementation(
347
+ (async (_: string): Promise<Stats> =>
348
+ ({
349
+ mtime: new Date(),
350
+ }) as Stats) as unknown as typeof fsPromises.stat,
351
+ );
352
+
353
+ const result = await resumeCommand?.completion?.(mockContext, 'a');
354
+
355
+ expect(result).toEqual(['alpha']);
356
+ });
357
+
358
+ it('should suggest filenames sorted by modified time (newest first)', async () => {
359
+ const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
360
+ const date = new Date();
361
+ mockFs.readdir.mockImplementation(
362
+ (async (_: string): Promise<string[]> =>
363
+ fakeFiles) as unknown as typeof fsPromises.readdir,
364
+ );
365
+ mockFs.stat.mockImplementation((async (
366
+ path: string,
367
+ ): Promise<Stats> => {
368
+ if (path.endsWith('test1.json')) {
369
+ return { mtime: date } as Stats;
370
+ }
371
+ return { mtime: new Date(date.getTime() + 1000) } as Stats;
372
+ }) as unknown as typeof fsPromises.stat);
373
+
374
+ const result = await resumeCommand?.completion?.(mockContext, '');
375
+ // Sort items by last modified time (newest first)
376
+ expect(result).toEqual(['test2', 'test1']);
377
+ });
378
+ });
379
+ });
380
+
381
+ describe('delete subcommand', () => {
382
+ let deleteCommand: SlashCommand;
383
+ const tag = 'my-tag';
384
+ beforeEach(() => {
385
+ deleteCommand = getSubCommand('delete');
386
+ });
387
+
388
+ it('should return an error if tag is missing', async () => {
389
+ const result = await deleteCommand?.action?.(mockContext, ' ');
390
+ expect(result).toEqual({
391
+ type: 'message',
392
+ messageType: 'error',
393
+ content: 'Missing tag. Usage: /resume delete <tag>',
394
+ });
395
+ });
396
+
397
+ it('should return an error if checkpoint is not found', async () => {
398
+ mockDeleteCheckpoint.mockResolvedValue(false);
399
+ const result = await deleteCommand?.action?.(mockContext, tag);
400
+ expect(result).toEqual({
401
+ type: 'message',
402
+ messageType: 'error',
403
+ content: `Error: No checkpoint found with tag '${tag}'.`,
404
+ });
405
+ });
406
+
407
+ it('should delete the conversation', async () => {
408
+ const result = await deleteCommand?.action?.(mockContext, tag);
409
+
410
+ expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag);
411
+ expect(result).toEqual({
412
+ type: 'message',
413
+ messageType: 'info',
414
+ content: `Conversation checkpoint '${tag}' has been deleted.`,
415
+ });
416
+ });
417
+
418
+ describe('completion', () => {
419
+ it('should provide completion suggestions', async () => {
420
+ const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
421
+ mockFs.readdir.mockImplementation(
422
+ (async (_: string): Promise<string[]> =>
423
+ fakeFiles) as unknown as typeof fsPromises.readdir,
424
+ );
425
+
426
+ mockFs.stat.mockImplementation(
427
+ (async (_: string): Promise<Stats> =>
428
+ ({
429
+ mtime: new Date(),
430
+ }) as Stats) as unknown as typeof fsPromises.stat,
431
+ );
432
+
433
+ const result = await deleteCommand?.completion?.(mockContext, 'a');
434
+
435
+ expect(result).toEqual(['alpha']);
436
+ });
437
+ });
438
+ });
439
+
440
+ describe('share subcommand', () => {
441
+ let shareCommand: SlashCommand;
442
+ const mockHistory = [
443
+ { role: 'user', parts: [{ text: 'context' }] },
444
+ { role: 'model', parts: [{ text: 'context response' }] },
445
+ { role: 'user', parts: [{ text: 'Hello' }] },
446
+ { role: 'model', parts: [{ text: 'Hi there!' }] },
447
+ ];
448
+
449
+ beforeEach(() => {
450
+ shareCommand = getSubCommand('share');
451
+ vi.spyOn(process, 'cwd').mockReturnValue(
452
+ path.resolve('/usr/local/google/home/myuser/gemini-cli'),
453
+ );
454
+ vi.spyOn(Date, 'now').mockReturnValue(1234567890);
455
+ mockGetHistory.mockReturnValue(mockHistory);
456
+ mockFs.writeFile.mockClear();
457
+ });
458
+
459
+ it('should default to a json file if no path is provided', async () => {
460
+ const result = await shareCommand?.action?.(mockContext, '');
461
+ const expectedPath = path.join(
462
+ process.cwd(),
463
+ 'gemini-conversation-1234567890.json',
464
+ );
465
+ expect(mockExport).toHaveBeenCalledWith({
466
+ history: mockHistory,
467
+ filePath: expectedPath,
468
+ });
469
+ expect(result).toEqual({
470
+ type: 'message',
471
+ messageType: 'info',
472
+ content: `Conversation shared to ${expectedPath}`,
473
+ });
474
+ });
475
+
476
+ it('should share the conversation to a JSON file', async () => {
477
+ const filePath = 'my-chat.json';
478
+ const result = await shareCommand?.action?.(mockContext, filePath);
479
+ const expectedPath = path.join(process.cwd(), 'my-chat.json');
480
+ expect(mockExport).toHaveBeenCalledWith({
481
+ history: mockHistory,
482
+ filePath: expectedPath,
483
+ });
484
+ expect(result).toEqual({
485
+ type: 'message',
486
+ messageType: 'info',
487
+ content: `Conversation shared to ${expectedPath}`,
488
+ });
489
+ });
490
+
491
+ it('should share the conversation to a Markdown file', async () => {
492
+ const filePath = 'my-chat.md';
493
+ const result = await shareCommand?.action?.(mockContext, filePath);
494
+ const expectedPath = path.join(process.cwd(), 'my-chat.md');
495
+ expect(mockExport).toHaveBeenCalledWith({
496
+ history: mockHistory,
497
+ filePath: expectedPath,
498
+ });
499
+ expect(result).toEqual({
500
+ type: 'message',
501
+ messageType: 'info',
502
+ content: `Conversation shared to ${expectedPath}`,
503
+ });
504
+ });
505
+
506
+ it('should return an error for unsupported file extensions', async () => {
507
+ const filePath = 'my-chat.txt';
508
+ const result = await shareCommand?.action?.(mockContext, filePath);
509
+ expect(mockExport).not.toHaveBeenCalled();
510
+ expect(result).toEqual({
511
+ type: 'message',
512
+ messageType: 'error',
513
+ content: 'Invalid file format. Only .md and .json are supported.',
514
+ });
515
+ });
516
+
517
+ it('should inform if there is no conversation to share', async () => {
518
+ mockGetHistory.mockReturnValue([
519
+ { role: 'user', parts: [{ text: 'context' }] },
520
+ ]);
521
+ const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
522
+ expect(mockExport).not.toHaveBeenCalled();
523
+ expect(result).toEqual({
524
+ type: 'message',
525
+ messageType: 'info',
526
+ content: 'No conversation found to share.',
527
+ });
528
+ });
529
+
530
+ it('should handle errors during file writing', async () => {
531
+ const error = new Error('Permission denied');
532
+ mockExport.mockRejectedValue(error);
533
+ const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
534
+ expect(result).toEqual({
535
+ type: 'message',
536
+ messageType: 'error',
537
+ content: `Error sharing conversation: ${error.message}`,
538
+ });
539
+ });
540
+
541
+ it('should output valid JSON schema', async () => {
542
+ const filePath = 'my-chat.json';
543
+ await shareCommand?.action?.(mockContext, filePath);
544
+ const expectedPath = path.join(process.cwd(), 'my-chat.json');
545
+ expect(mockExport).toHaveBeenCalledWith({
546
+ history: mockHistory,
547
+ filePath: expectedPath,
548
+ });
549
+ });
550
+
551
+ it('should output correct markdown format', async () => {
552
+ const filePath = 'my-chat.md';
553
+ await shareCommand?.action?.(mockContext, filePath);
554
+ const expectedPath = path.join(process.cwd(), 'my-chat.md');
555
+ expect(mockExport).toHaveBeenCalledWith({
556
+ history: mockHistory,
557
+ filePath: expectedPath,
558
+ });
559
+ });
560
+ });
561
+
562
+ describe('serializeHistoryToMarkdown', () => {
563
+ it('should correctly serialize chat history to Markdown with icons', () => {
564
+ const history: Content[] = [
565
+ { role: 'user', parts: [{ text: 'Hello' }] },
566
+ { role: 'model', parts: [{ text: 'Hi there!' }] },
567
+ { role: 'user', parts: [{ text: 'How are you?' }] },
568
+ ];
569
+
570
+ const expectedMarkdown =
571
+ '## USER 🧑‍💻\n\nHello\n\n---\n\n' +
572
+ '## MODEL ✨\n\nHi there!\n\n---\n\n' +
573
+ '## USER 🧑‍💻\n\nHow are you?';
574
+
575
+ const result = serializeHistoryToMarkdown(history);
576
+ expect(result).toBe(expectedMarkdown);
577
+ });
578
+
579
+ it('should handle empty history', () => {
580
+ const history: Content[] = [];
581
+ const result = serializeHistoryToMarkdown(history);
582
+ expect(result).toBe('');
583
+ });
584
+
585
+ it('should handle items with no text parts', () => {
586
+ const history: Content[] = [
587
+ { role: 'user', parts: [{ text: 'Hello' }] },
588
+ { role: 'model', parts: [] },
589
+ { role: 'user', parts: [{ text: 'How are you?' }] },
590
+ ];
591
+
592
+ const expectedMarkdown = `## USER 🧑‍💻
593
+
594
+ Hello
595
+
596
+ ---
597
+
598
+ ## MODEL ✨
599
+
600
+
601
+
602
+ ---
603
+
604
+ ## USER 🧑‍💻
605
+
606
+ How are you?`;
607
+
608
+ const result = serializeHistoryToMarkdown(history);
609
+ expect(result).toBe(expectedMarkdown);
610
+ });
611
+
612
+ it('should correctly serialize function calls and responses', () => {
613
+ const history: Content[] = [
614
+ {
615
+ role: 'user',
616
+ parts: [{ text: 'Please call a function.' }],
617
+ },
618
+ {
619
+ role: 'model',
620
+ parts: [
621
+ {
622
+ functionCall: {
623
+ name: 'my-function',
624
+ args: { arg1: 'value1' },
625
+ },
626
+ },
627
+ ],
628
+ },
629
+ {
630
+ role: 'user',
631
+ parts: [
632
+ {
633
+ functionResponse: {
634
+ name: 'my-function',
635
+ response: { result: 'success' },
636
+ },
637
+ },
638
+ ],
639
+ },
640
+ ];
641
+
642
+ const expectedMarkdown = `## USER 🧑‍💻
643
+
644
+ Please call a function.
645
+
646
+ ---
647
+
648
+ ## MODEL ✨
649
+
650
+ **Tool Command**:
651
+ \`\`\`json
652
+ {
653
+ "name": "my-function",
654
+ "args": {
655
+ "arg1": "value1"
656
+ }
657
+ }
658
+ \`\`\`
659
+
660
+ ---
661
+
662
+ ## USER 🧑‍💻
663
+
664
+ **Tool Response**:
665
+ \`\`\`json
666
+ {
667
+ "name": "my-function",
668
+ "response": {
669
+ "result": "success"
670
+ }
671
+ }
672
+ \`\`\``;
673
+
674
+ const result = serializeHistoryToMarkdown(history);
675
+ expect(result).toBe(expectedMarkdown);
676
+ });
677
+
678
+ it('should handle items with undefined role', () => {
679
+ const history: Array<Partial<Content>> = [
680
+ { role: 'user', parts: [{ text: 'Hello' }] },
681
+ { parts: [{ text: 'Hi there!' }] },
682
+ ];
683
+
684
+ const expectedMarkdown = `## USER 🧑‍💻
685
+
686
+ Hello
687
+
688
+ ---
689
+
690
+ ## MODEL ✨
691
+
692
+ Hi there!`;
693
+
694
+ const result = serializeHistoryToMarkdown(history as Content[]);
695
+ expect(result).toBe(expectedMarkdown);
696
+ });
697
+ describe('debug subcommand', () => {
698
+ let mockGetLatestApiRequest: ReturnType<typeof vi.fn>;
699
+
700
+ beforeEach(() => {
701
+ mockGetLatestApiRequest = vi.fn();
702
+ if (!mockContext.services.agentContext!.config) {
703
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
704
+ (mockContext.services.agentContext!.config as any) = {};
705
+ }
706
+ mockContext.services.agentContext!.config.getLatestApiRequest =
707
+ mockGetLatestApiRequest;
708
+ vi.spyOn(process, 'cwd').mockReturnValue('/project/root');
709
+ vi.spyOn(Date, 'now').mockReturnValue(1234567890);
710
+ mockFs.writeFile.mockClear();
711
+ });
712
+
713
+ it('should return an error if no API request is found', async () => {
714
+ mockGetLatestApiRequest.mockReturnValue(undefined);
715
+
716
+ const result = await debugCommand.action?.(mockContext, '');
717
+
718
+ expect(result).toEqual({
719
+ type: 'message',
720
+ messageType: 'error',
721
+ content: 'No recent API request found to export.',
722
+ });
723
+ expect(mockFs.writeFile).not.toHaveBeenCalled();
724
+ });
725
+
726
+ it('should convert and write the API request to a json file', async () => {
727
+ const mockRequest = {
728
+ contents: [{ role: 'user', parts: [{ text: 'test' }] }],
729
+ };
730
+ mockGetLatestApiRequest.mockReturnValue(mockRequest);
731
+
732
+ const result = await debugCommand.action?.(mockContext, '');
733
+
734
+ const expectedFilename = 'gcli-request-1234567890.json';
735
+ const expectedPath = path.join('/project/root', expectedFilename);
736
+
737
+ expect(mockFs.writeFile).toHaveBeenCalledWith(
738
+ expectedPath,
739
+ expect.stringContaining('"role": "user"'),
740
+ );
741
+ expect(result).toEqual({
742
+ type: 'message',
743
+ messageType: 'info',
744
+ content: `Debug API request saved to ${expectedFilename}`,
745
+ });
746
+ });
747
+
748
+ it('should handle errors during file write', async () => {
749
+ const mockRequest = { contents: [] };
750
+ mockGetLatestApiRequest.mockReturnValue(mockRequest);
751
+ mockFs.writeFile.mockRejectedValue(new Error('Write failed'));
752
+
753
+ const result = await debugCommand.action?.(mockContext, '');
754
+
755
+ expect(result).toEqual({
756
+ type: 'message',
757
+ messageType: 'error',
758
+ content: 'Error saving debug request: Write failed',
759
+ });
760
+ });
761
+ });
762
+ });
763
+ });
packages/cli/src/ui/commands/chatCommand.ts ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fsPromises from 'node:fs/promises';
8
+ import React from 'react';
9
+ import { Text } from 'ink';
10
+ import { theme } from '../semantic-colors.js';
11
+ import type {
12
+ CommandContext,
13
+ SlashCommand,
14
+ SlashCommandActionReturn,
15
+ } from './types.js';
16
+ import { CommandKind } from './types.js';
17
+ import {
18
+ decodeTagName,
19
+ type MessageActionReturn,
20
+ INITIAL_HISTORY_LENGTH,
21
+ } from '@google/gemini-cli-core';
22
+ import path from 'node:path';
23
+ import type {
24
+ HistoryItemWithoutId,
25
+ HistoryItemChatList,
26
+ ChatDetail,
27
+ } from '../types.js';
28
+ import { MessageType } from '../types.js';
29
+ import { exportHistoryToFile } from '../utils/historyExportUtils.js';
30
+ import { convertToRestPayload } from '@google/gemini-cli-core';
31
+
32
+ const CHECKPOINT_MENU_GROUP = 'checkpoints';
33
+
34
+ const getSavedChatTags = async (
35
+ context: CommandContext,
36
+ mtSortDesc: boolean,
37
+ ): Promise<ChatDetail[]> => {
38
+ const cfg = context.services.agentContext?.config;
39
+ const geminiDir = cfg?.storage?.getProjectTempDir();
40
+ if (!geminiDir) {
41
+ return [];
42
+ }
43
+ try {
44
+ const file_head = 'checkpoint-';
45
+ const file_tail = '.json';
46
+ const files = await fsPromises.readdir(geminiDir);
47
+ const chatDetails: ChatDetail[] = [];
48
+
49
+ for (const file of files) {
50
+ if (file.startsWith(file_head) && file.endsWith(file_tail)) {
51
+ const filePath = path.join(geminiDir, file);
52
+ const stats = await fsPromises.stat(filePath);
53
+ const tagName = file.slice(file_head.length, -file_tail.length);
54
+ chatDetails.push({
55
+ name: decodeTagName(tagName),
56
+ mtime: stats.mtime.toISOString(),
57
+ });
58
+ }
59
+ }
60
+
61
+ chatDetails.sort((a, b) =>
62
+ mtSortDesc
63
+ ? b.mtime.localeCompare(a.mtime)
64
+ : a.mtime.localeCompare(b.mtime),
65
+ );
66
+
67
+ return chatDetails;
68
+ } catch {
69
+ return [];
70
+ }
71
+ };
72
+
73
+ const listCommand: SlashCommand = {
74
+ name: 'list',
75
+ description: 'List saved manual conversation checkpoints',
76
+ kind: CommandKind.BUILT_IN,
77
+ autoExecute: true,
78
+ takesArgs: false,
79
+ action: async (context): Promise<void> => {
80
+ const chatDetails = await getSavedChatTags(context, false);
81
+
82
+ const item: HistoryItemChatList = {
83
+ type: MessageType.CHAT_LIST,
84
+ chats: chatDetails,
85
+ };
86
+
87
+ context.ui.addItem(item);
88
+ },
89
+ };
90
+
91
+ const saveCommand: SlashCommand = {
92
+ name: 'save',
93
+ description:
94
+ 'Save the current conversation as a checkpoint. Usage: /resume save <tag>',
95
+ kind: CommandKind.BUILT_IN,
96
+ autoExecute: false,
97
+ action: async (context, args): Promise<SlashCommandActionReturn | void> => {
98
+ const tag = args.trim();
99
+ if (!tag) {
100
+ return {
101
+ type: 'message',
102
+ messageType: 'error',
103
+ content: 'Missing tag. Usage: /resume save <tag>',
104
+ };
105
+ }
106
+
107
+ const { logger } = context.services;
108
+ const config = context.services.agentContext?.config;
109
+ await logger.initialize();
110
+
111
+ if (!context.overwriteConfirmed) {
112
+ const exists = await logger.checkpointExists(tag);
113
+ if (exists) {
114
+ return {
115
+ type: 'confirm_action',
116
+ prompt: React.createElement(
117
+ Text,
118
+ null,
119
+ 'A checkpoint with the tag ',
120
+ React.createElement(Text, { color: theme.text.accent }, tag),
121
+ ' already exists. Do you want to overwrite it?',
122
+ ),
123
+ originalInvocation: {
124
+ raw: context.invocation?.raw || `/resume save ${tag}`,
125
+ },
126
+ };
127
+ }
128
+ }
129
+
130
+ const chat = context.services.agentContext?.geminiClient?.getChat();
131
+ if (!chat) {
132
+ return {
133
+ type: 'message',
134
+ messageType: 'error',
135
+ content: 'No chat client available to save conversation.',
136
+ };
137
+ }
138
+
139
+ const history = chat.getHistory();
140
+ if (history.length > INITIAL_HISTORY_LENGTH) {
141
+ const authType = config?.getContentGeneratorConfig()?.authType;
142
+ await logger.saveCheckpoint({ history, authType }, tag);
143
+ return {
144
+ type: 'message',
145
+ messageType: 'info',
146
+ content: `Conversation checkpoint saved with tag: ${decodeTagName(
147
+ tag,
148
+ )}.`,
149
+ };
150
+ } else {
151
+ return {
152
+ type: 'message',
153
+ messageType: 'info',
154
+ content: 'No conversation found to save.',
155
+ };
156
+ }
157
+ },
158
+ };
159
+
160
+ const resumeCheckpointCommand: SlashCommand = {
161
+ name: 'resume',
162
+ altNames: ['load'],
163
+ description:
164
+ 'Resume a conversation from a checkpoint. Usage: /resume resume <tag>',
165
+ kind: CommandKind.BUILT_IN,
166
+ autoExecute: true,
167
+ action: async (context, args) => {
168
+ const tag = args.trim();
169
+ if (!tag) {
170
+ return {
171
+ type: 'message',
172
+ messageType: 'error',
173
+ content: 'Missing tag. Usage: /resume resume <tag>',
174
+ };
175
+ }
176
+
177
+ const { logger } = context.services;
178
+ const config = context.services.agentContext?.config;
179
+ await logger.initialize();
180
+ const checkpoint = await logger.loadCheckpoint(tag);
181
+ const conversation = checkpoint.history;
182
+
183
+ if (conversation.length === 0) {
184
+ return {
185
+ type: 'message',
186
+ messageType: 'info',
187
+ content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`,
188
+ };
189
+ }
190
+
191
+ const currentAuthType = config?.getContentGeneratorConfig()?.authType;
192
+ if (
193
+ checkpoint.authType &&
194
+ currentAuthType &&
195
+ checkpoint.authType !== currentAuthType
196
+ ) {
197
+ return {
198
+ type: 'message',
199
+ messageType: 'error',
200
+ content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
201
+ };
202
+ }
203
+
204
+ const rolemap: { [key: string]: MessageType } = {
205
+ user: MessageType.USER,
206
+ model: MessageType.GEMINI,
207
+ };
208
+
209
+ const uiHistory: HistoryItemWithoutId[] = [];
210
+
211
+ for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
212
+ const text =
213
+ item.parts
214
+ ?.filter((m) => !!m.text)
215
+ .map((m) => m.text)
216
+ .join('') || '';
217
+ if (!text) {
218
+ continue;
219
+ }
220
+
221
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
222
+ uiHistory.push({
223
+ type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
224
+ text,
225
+ } as HistoryItemWithoutId);
226
+ }
227
+ return {
228
+ type: 'load_history',
229
+ history: uiHistory,
230
+ clientHistory: conversation,
231
+ };
232
+ },
233
+ completion: async (context, partialArg) => {
234
+ const chatDetails = await getSavedChatTags(context, true);
235
+ return chatDetails
236
+ .map((chat) => chat.name)
237
+ .filter((name) => name.startsWith(partialArg));
238
+ },
239
+ };
240
+
241
+ const deleteCommand: SlashCommand = {
242
+ name: 'delete',
243
+ description: 'Delete a conversation checkpoint. Usage: /resume delete <tag>',
244
+ kind: CommandKind.BUILT_IN,
245
+ autoExecute: true,
246
+ action: async (context, args): Promise<MessageActionReturn> => {
247
+ const tag = args.trim();
248
+ if (!tag) {
249
+ return {
250
+ type: 'message',
251
+ messageType: 'error',
252
+ content: 'Missing tag. Usage: /resume delete <tag>',
253
+ };
254
+ }
255
+
256
+ const { logger } = context.services;
257
+ await logger.initialize();
258
+ const deleted = await logger.deleteCheckpoint(tag);
259
+
260
+ if (deleted) {
261
+ return {
262
+ type: 'message',
263
+ messageType: 'info',
264
+ content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
265
+ };
266
+ } else {
267
+ return {
268
+ type: 'message',
269
+ messageType: 'error',
270
+ content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`,
271
+ };
272
+ }
273
+ },
274
+ completion: async (context, partialArg) => {
275
+ const chatDetails = await getSavedChatTags(context, true);
276
+ return chatDetails
277
+ .map((chat) => chat.name)
278
+ .filter((name) => name.startsWith(partialArg));
279
+ },
280
+ };
281
+
282
+ const shareCommand: SlashCommand = {
283
+ name: 'share',
284
+ description:
285
+ 'Share the current conversation to a markdown or json file. Usage: /resume share <file>',
286
+ kind: CommandKind.BUILT_IN,
287
+ autoExecute: false,
288
+ action: async (context, args): Promise<MessageActionReturn> => {
289
+ let filePathArg = args.trim();
290
+ if (!filePathArg) {
291
+ filePathArg = `gemini-conversation-${Date.now()}.json`;
292
+ }
293
+
294
+ const filePath = path.resolve(filePathArg);
295
+ const extension = path.extname(filePath);
296
+ if (extension !== '.md' && extension !== '.json') {
297
+ return {
298
+ type: 'message',
299
+ messageType: 'error',
300
+ content: 'Invalid file format. Only .md and .json are supported.',
301
+ };
302
+ }
303
+
304
+ const chat = context.services.agentContext?.geminiClient?.getChat();
305
+ if (!chat) {
306
+ return {
307
+ type: 'message',
308
+ messageType: 'error',
309
+ content: 'No chat client available to share conversation.',
310
+ };
311
+ }
312
+
313
+ const history = chat.getHistory();
314
+
315
+ // An empty conversation has a hidden message that sets up the context for
316
+ // the chat. Thus, to check whether a conversation has been started, we
317
+ // can't check for length 0.
318
+ if (history.length <= INITIAL_HISTORY_LENGTH) {
319
+ return {
320
+ type: 'message',
321
+ messageType: 'info',
322
+ content: 'No conversation found to share.',
323
+ };
324
+ }
325
+
326
+ try {
327
+ await exportHistoryToFile({ history, filePath });
328
+ return {
329
+ type: 'message',
330
+ messageType: 'info',
331
+ content: `Conversation shared to ${filePath}`,
332
+ };
333
+ } catch (err) {
334
+ const errorMessage = err instanceof Error ? err.message : String(err);
335
+ return {
336
+ type: 'message',
337
+ messageType: 'error',
338
+ content: `Error sharing conversation: ${errorMessage}`,
339
+ };
340
+ }
341
+ },
342
+ };
343
+
344
+ export const debugCommand: SlashCommand = {
345
+ name: 'debug',
346
+ description: 'Export the most recent API request as a JSON payload',
347
+ kind: CommandKind.BUILT_IN,
348
+ autoExecute: true,
349
+ action: async (context): Promise<MessageActionReturn> => {
350
+ const req = context.services.agentContext?.config.getLatestApiRequest();
351
+ if (!req) {
352
+ return {
353
+ type: 'message',
354
+ messageType: 'error',
355
+ content: 'No recent API request found to export.',
356
+ };
357
+ }
358
+
359
+ const restPayload = convertToRestPayload(req);
360
+ const filename = `gcli-request-${Date.now()}.json`;
361
+ const filePath = path.join(process.cwd(), filename);
362
+
363
+ try {
364
+ await fsPromises.writeFile(
365
+ filePath,
366
+ JSON.stringify(restPayload, null, 2),
367
+ );
368
+ return {
369
+ type: 'message',
370
+ messageType: 'info',
371
+ content: `Debug API request saved to ${filename}`,
372
+ };
373
+ } catch (err) {
374
+ const errorMessage = err instanceof Error ? err.message : String(err);
375
+ return {
376
+ type: 'message',
377
+ messageType: 'error',
378
+ content: `Error saving debug request: ${errorMessage}`,
379
+ };
380
+ }
381
+ },
382
+ };
383
+
384
+ export const checkpointSubCommands: SlashCommand[] = [
385
+ listCommand,
386
+ saveCommand,
387
+ resumeCheckpointCommand,
388
+ deleteCommand,
389
+ shareCommand,
390
+ ];
391
+
392
+ const checkpointCompatibilityCommand: SlashCommand = {
393
+ name: 'checkpoints',
394
+ altNames: ['checkpoint'],
395
+ description: 'Compatibility command for nested checkpoint operations',
396
+ kind: CommandKind.BUILT_IN,
397
+ hidden: true,
398
+ autoExecute: false,
399
+ subCommands: checkpointSubCommands,
400
+ };
401
+
402
+ export const chatResumeSubCommands: SlashCommand[] = [
403
+ ...checkpointSubCommands.map((subCommand) => ({
404
+ ...subCommand,
405
+ suggestionGroup: CHECKPOINT_MENU_GROUP,
406
+ })),
407
+ checkpointCompatibilityCommand,
408
+ ];
409
+
410
+ import { parseSlashCommand } from '../../utils/commands.js';
411
+
412
+ export const chatCommand: SlashCommand = {
413
+ name: 'chat',
414
+ description: 'Browse auto-saved conversations and manage chat checkpoints',
415
+ kind: CommandKind.BUILT_IN,
416
+ autoExecute: true,
417
+ action: async (context, args) => {
418
+ if (args) {
419
+ const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
420
+ if (parsed.commandToExecute?.action) {
421
+ return parsed.commandToExecute.action(context, parsed.args);
422
+ }
423
+ }
424
+ return {
425
+ type: 'dialog',
426
+ dialog: 'sessionBrowser',
427
+ };
428
+ },
429
+ subCommands: chatResumeSubCommands,
430
+ };
packages/cli/src/ui/commands/clearCommand.test.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
8
+ import { clearCommand } from './clearCommand.js';
9
+ import { type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+
12
+ // Mock the telemetry service
13
+ vi.mock('@google/gemini-cli-core', async () => {
14
+ const actual = await vi.importActual('@google/gemini-cli-core');
15
+ return {
16
+ ...actual,
17
+ uiTelemetryService: {
18
+ setLastPromptTokenCount: vi.fn(),
19
+ clear: vi.fn(),
20
+ },
21
+ };
22
+ });
23
+
24
+ import { uiTelemetryService, type GeminiClient } from '@google/gemini-cli-core';
25
+
26
+ describe('clearCommand', () => {
27
+ let mockContext: CommandContext;
28
+ let mockResetChat: ReturnType<typeof vi.fn>;
29
+ let mockHintClear: ReturnType<typeof vi.fn>;
30
+
31
+ beforeEach(() => {
32
+ mockResetChat = vi.fn().mockResolvedValue(undefined);
33
+ mockHintClear = vi.fn();
34
+ const mockGetChatRecordingService = vi.fn();
35
+ vi.clearAllMocks();
36
+
37
+ mockContext = createMockCommandContext({
38
+ services: {
39
+ agentContext: {
40
+ config: {
41
+ getEnableHooks: vi.fn().mockReturnValue(false),
42
+ resetNewSessionState: vi.fn(),
43
+ getMessageBus: vi.fn().mockReturnValue(undefined),
44
+ getHookSystem: vi.fn().mockReturnValue({
45
+ fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
46
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
47
+ }),
48
+ injectionService: {
49
+ clear: mockHintClear,
50
+ },
51
+ },
52
+ geminiClient: {
53
+ resetChat: mockResetChat,
54
+ getChat: () => ({
55
+ getChatRecordingService: mockGetChatRecordingService,
56
+ }),
57
+ } as unknown as GeminiClient,
58
+ },
59
+ },
60
+ });
61
+ });
62
+
63
+ it('should set debug message, reset chat, reset telemetry, clear hints, and clear UI when config is available', async () => {
64
+ if (!clearCommand.action) {
65
+ throw new Error('clearCommand must have an action.');
66
+ }
67
+
68
+ await clearCommand.action(mockContext, '');
69
+
70
+ expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
71
+ 'Clearing terminal and resetting chat.',
72
+ );
73
+ expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
74
+
75
+ expect(mockResetChat).toHaveBeenCalledTimes(1);
76
+ expect(mockHintClear).toHaveBeenCalledTimes(1);
77
+ expect(
78
+ mockContext.services.agentContext?.config.resetNewSessionState,
79
+ ).toHaveBeenCalledTimes(1);
80
+ expect(uiTelemetryService.clear).toHaveBeenCalled();
81
+ expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1);
82
+ expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
83
+
84
+ // Check the order of operations.
85
+ const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
86
+ .invocationCallOrder[0];
87
+ const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
88
+ const resetTelemetryOrder = (uiTelemetryService.clear as Mock).mock
89
+ .invocationCallOrder[0];
90
+ const clearOrder = (mockContext.ui.clear as Mock).mock
91
+ .invocationCallOrder[0];
92
+
93
+ expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
94
+ expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
95
+ expect(resetTelemetryOrder).toBeLessThan(clearOrder);
96
+ });
97
+
98
+ it('should not attempt to reset chat if config service is not available', async () => {
99
+ if (!clearCommand.action) {
100
+ throw new Error('clearCommand must have an action.');
101
+ }
102
+
103
+ const nullConfigContext = createMockCommandContext({
104
+ services: {
105
+ agentContext: null,
106
+ },
107
+ });
108
+
109
+ await clearCommand.action(nullConfigContext, '');
110
+
111
+ expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
112
+ 'Clearing terminal.',
113
+ );
114
+ expect(mockResetChat).not.toHaveBeenCalled();
115
+ expect(uiTelemetryService.clear).toHaveBeenCalled();
116
+ expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1);
117
+ expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
118
+ });
119
+ });
packages/cli/src/ui/commands/clearCommand.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ uiTelemetryService,
9
+ SessionEndReason,
10
+ SessionStartSource,
11
+ flushTelemetry,
12
+ resetBrowserSession,
13
+ } from '@google/gemini-cli-core';
14
+ import { CommandKind, type SlashCommand } from './types.js';
15
+ import { MessageType } from '../types.js';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ export const clearCommand: SlashCommand = {
19
+ name: 'clear',
20
+ altNames: ['new'],
21
+ description: 'Clear the screen and start a new session',
22
+ kind: CommandKind.BUILT_IN,
23
+ autoExecute: true,
24
+ action: async (context, _args) => {
25
+ const geminiClient = context.services.agentContext?.geminiClient;
26
+ const config = context.services.agentContext?.config;
27
+
28
+ // Fire SessionEnd hook before clearing
29
+ const hookSystem = config?.getHookSystem();
30
+ if (hookSystem) {
31
+ await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
32
+ }
33
+
34
+ // Reset user steering hints
35
+ config?.injectionService.clear();
36
+
37
+ // Start a new conversation recording with a new session ID
38
+ // We MUST do this before calling resetChat() so the new ChatRecordingService
39
+ // initialized by GeminiChat picks up the new session ID.
40
+ let newSessionId: string | undefined;
41
+ if (config) {
42
+ newSessionId = randomUUID();
43
+ config.resetNewSessionState(newSessionId);
44
+ }
45
+
46
+ if (geminiClient) {
47
+ context.ui.setDebugMessage('Clearing terminal and resetting chat.');
48
+
49
+ // Close persistent browser sessions before resetting chat
50
+ await resetBrowserSession();
51
+
52
+ // If resetChat fails, the exception will propagate and halt the command,
53
+ // which is the correct behavior to signal a failure to the user.
54
+ await geminiClient.resetChat();
55
+ } else {
56
+ context.ui.setDebugMessage('Clearing terminal.');
57
+ }
58
+
59
+ // Fire SessionStart hook after clearing
60
+ let result;
61
+ if (hookSystem) {
62
+ result = await hookSystem.fireSessionStartEvent(SessionStartSource.Clear);
63
+ }
64
+
65
+ // Give the event loop a chance to process any pending telemetry operations
66
+ // This ensures logger.emit() calls have fully propagated to the BatchLogRecordProcessor
67
+ await new Promise((resolve) => setImmediate(resolve));
68
+
69
+ // Flush telemetry to ensure hooks are written to disk immediately
70
+ // This is critical for tests and environments with I/O latency
71
+ if (config) {
72
+ await flushTelemetry(config);
73
+ }
74
+
75
+ uiTelemetryService.clear(newSessionId);
76
+ context.ui.clear();
77
+
78
+ if (result?.systemMessage) {
79
+ context.ui.addItem(
80
+ {
81
+ type: MessageType.INFO,
82
+ text: result.systemMessage,
83
+ },
84
+ Date.now(),
85
+ );
86
+ }
87
+ },
88
+ };
packages/cli/src/ui/commands/commandsCommand.test.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { Storage, type Config } from '@google/gemini-cli-core';
9
+ import { commandsCommand } from './commandsCommand.js';
10
+ import { MessageType } from '../types.js';
11
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
12
+ import type { CommandContext } from './types.js';
13
+ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
14
+
15
+ vi.mock('../../services/FileCommandLoader.js');
16
+
17
+ vi.mock('@google/gemini-cli-core', async () => {
18
+ const actual = await vi.importActual<
19
+ typeof import('@google/gemini-cli-core')
20
+ >('@google/gemini-cli-core');
21
+ return {
22
+ ...actual,
23
+ Storage: class extends actual.Storage {
24
+ static override getUserCommandsDir() {
25
+ return '/mock/user/commands';
26
+ }
27
+ override getProjectCommandsDir() {
28
+ return '/mock/project/commands';
29
+ }
30
+ },
31
+ };
32
+ });
33
+
34
+ describe('commandsCommand', () => {
35
+ let context: CommandContext;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ context = createMockCommandContext({
40
+ ui: {
41
+ reloadCommands: vi.fn(),
42
+ addItem: vi.fn(),
43
+ },
44
+ services: {
45
+ agentContext: {
46
+ getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
47
+ getFolderTrust: vi.fn().mockReturnValue(false),
48
+ isTrustedFolder: vi.fn().mockReturnValue(false),
49
+ getExtensions: vi.fn().mockReturnValue([
50
+ { name: 'ext1', path: '/mock/ext1', isActive: true },
51
+ { name: 'ext2', path: '/mock/ext2', isActive: false },
52
+ ]),
53
+ storage: new Storage('/mock/project'),
54
+ } as unknown as Config,
55
+ },
56
+ });
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ describe('default action', () => {
64
+ it('should return an info message prompting subcommand usage', async () => {
65
+ const result = await commandsCommand.action!(context, '');
66
+
67
+ expect(result).toEqual({
68
+ type: 'message',
69
+ messageType: 'info',
70
+ content:
71
+ 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
72
+ });
73
+ });
74
+ });
75
+
76
+ describe('list', () => {
77
+ it('should list .toml files from available sources', async () => {
78
+ vi.mocked(
79
+ FileCommandLoader.prototype.listAvailableFiles,
80
+ ).mockResolvedValue([
81
+ {
82
+ displayName: 'User',
83
+ path: '/mock/user/commands',
84
+ files: ['user1.toml'],
85
+ },
86
+ {
87
+ displayName: 'Project',
88
+ path: '/mock/project/commands',
89
+ files: ['proj1.toml'],
90
+ },
91
+ {
92
+ displayName: 'Extension: ext1',
93
+ path: '/mock/ext1/commands',
94
+ files: ['ext1.toml'],
95
+ },
96
+ ]);
97
+
98
+ const listCmd = commandsCommand.subCommands!.find(
99
+ (s) => s.name === 'list',
100
+ )!;
101
+
102
+ await listCmd.action!(context, '');
103
+
104
+ expect(context.ui.addItem).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ type: MessageType.INFO,
107
+ text: expect.any(String),
108
+ }),
109
+ expect.any(Number),
110
+ );
111
+
112
+ // Snapshot the text content
113
+ const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0];
114
+
115
+ expect((addItemCall as { text: string }).text).toMatchSnapshot();
116
+ });
117
+
118
+ it('should show "No custom command files found" message if no .toml files exist', async () => {
119
+ vi.mocked(
120
+ FileCommandLoader.prototype.listAvailableFiles,
121
+ ).mockResolvedValue([]);
122
+
123
+ const listCmd = commandsCommand.subCommands!.find(
124
+ (s) => s.name === 'list',
125
+ )!;
126
+
127
+ const result = await listCmd.action!(context, '');
128
+
129
+ expect(result).toEqual({
130
+ type: 'message',
131
+ messageType: 'info',
132
+ content: expect.stringContaining(
133
+ 'No custom command files (.toml) found.',
134
+ ),
135
+ });
136
+ });
137
+ });
138
+
139
+ describe('reload', () => {
140
+ it('should call reloadCommands and show a success message', async () => {
141
+ const reloadCmd = commandsCommand.subCommands!.find(
142
+ (s) => s.name === 'reload',
143
+ )!;
144
+
145
+ await reloadCmd.action!(context, '');
146
+
147
+ expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
148
+ expect(context.ui.addItem).toHaveBeenCalledWith(
149
+ expect.objectContaining({
150
+ type: MessageType.INFO,
151
+ text: 'Custom commands reloaded successfully.',
152
+ }),
153
+ expect.any(Number),
154
+ );
155
+ });
156
+ });
157
+ });
packages/cli/src/ui/commands/commandsCommand.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
8
+ import {
9
+ type CommandContext,
10
+ type SlashCommand,
11
+ type SlashCommandActionReturn,
12
+ CommandKind,
13
+ } from './types.js';
14
+ import {
15
+ MessageType,
16
+ type HistoryItemError,
17
+ type HistoryItemInfo,
18
+ } from '../types.js';
19
+
20
+ /**
21
+ * Action for the default `/commands` invocation.
22
+ * Displays a message prompting the user to use a subcommand.
23
+ */
24
+ async function defaultAction(
25
+ _context: CommandContext,
26
+ _args: string,
27
+ ): Promise<void | SlashCommandActionReturn> {
28
+ return {
29
+ type: 'message',
30
+ messageType: 'info',
31
+ content:
32
+ 'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Action for `/commands list`.
38
+ * Lists available .toml command files from user, project, and extension directories.
39
+ */
40
+ async function listSubcommandAction(
41
+ context: CommandContext,
42
+ ): Promise<void | SlashCommandActionReturn> {
43
+ try {
44
+ const config = context.services.agentContext?.config ?? null;
45
+ const loader = new FileCommandLoader(config);
46
+ const groups = await loader.listAvailableFiles();
47
+
48
+ const results: string[] = [];
49
+ for (const group of groups) {
50
+ results.push(`### ${group.displayName} Commands (${group.path})`);
51
+ if (group.error) {
52
+ results.push(`- (Error reading directory: ${group.error})`);
53
+ } else {
54
+ group.files.forEach((file) => results.push(`- ${file}`));
55
+ }
56
+ }
57
+
58
+ results.push(
59
+ '\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
60
+ );
61
+
62
+ if (results.length === 1) {
63
+ // Only the note is present
64
+ return {
65
+ type: 'message',
66
+ messageType: 'info',
67
+ content:
68
+ 'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
69
+ };
70
+ }
71
+
72
+ context.ui.addItem(
73
+ {
74
+ type: MessageType.INFO,
75
+ text: results.join('\n'),
76
+ } as HistoryItemInfo,
77
+ Date.now(),
78
+ );
79
+ } catch (error) {
80
+ context.ui.addItem(
81
+ {
82
+ type: MessageType.ERROR,
83
+ text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`,
84
+ } as HistoryItemError,
85
+ Date.now(),
86
+ );
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Action for `/commands reload`.
92
+ * Triggers a full re-discovery and reload of all slash commands, including
93
+ * user/project-level .toml files, MCP prompts, and extension commands.
94
+ */
95
+ async function reloadAction(
96
+ context: CommandContext,
97
+ ): Promise<void | SlashCommandActionReturn> {
98
+ try {
99
+ context.ui.reloadCommands();
100
+
101
+ context.ui.addItem(
102
+ {
103
+ type: MessageType.INFO,
104
+ text: 'Custom commands reloaded successfully.',
105
+ } as HistoryItemInfo,
106
+ Date.now(),
107
+ );
108
+ } catch (error) {
109
+ context.ui.addItem(
110
+ {
111
+ type: MessageType.ERROR,
112
+ text: `Failed to reload commands: ${error instanceof Error ? error.message : String(error)}`,
113
+ } as HistoryItemError,
114
+ Date.now(),
115
+ );
116
+ }
117
+ }
118
+
119
+ export const commandsCommand: SlashCommand = {
120
+ name: 'commands',
121
+ description: 'Manage custom slash commands. Usage: /commands [list|reload]',
122
+ kind: CommandKind.BUILT_IN,
123
+ autoExecute: false,
124
+ subCommands: [
125
+ {
126
+ name: 'list',
127
+ description:
128
+ 'List available custom command .toml files. Usage: /commands list',
129
+ kind: CommandKind.BUILT_IN,
130
+ autoExecute: true,
131
+ action: listSubcommandAction,
132
+ },
133
+ {
134
+ name: 'reload',
135
+ altNames: ['refresh'],
136
+ description:
137
+ 'Reload custom command definitions from .toml files. Usage: /commands reload',
138
+ kind: CommandKind.BUILT_IN,
139
+ autoExecute: true,
140
+ action: reloadAction,
141
+ },
142
+ ],
143
+ action: defaultAction,
144
+ };
packages/cli/src/ui/commands/compressCommand.test.ts ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ CompressionStatus,
9
+ type ChatCompressionInfo,
10
+ type GeminiClient,
11
+ } from '@google/gemini-cli-core';
12
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
13
+ import { compressCommand } from './compressCommand.js';
14
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
15
+ import { MessageType } from '../types.js';
16
+
17
+ describe('compressCommand', () => {
18
+ let context: ReturnType<typeof createMockCommandContext>;
19
+ let mockTryCompressChat: ReturnType<typeof vi.fn>;
20
+
21
+ beforeEach(() => {
22
+ mockTryCompressChat = vi.fn();
23
+ context = createMockCommandContext({
24
+ services: {
25
+ agentContext: {
26
+ geminiClient: {
27
+ tryCompressChat: mockTryCompressChat,
28
+ } as unknown as GeminiClient,
29
+ },
30
+ },
31
+ });
32
+ });
33
+
34
+ it('should do nothing if a compression is already pending', async () => {
35
+ context.ui.pendingItem = {
36
+ type: MessageType.COMPRESSION,
37
+ compression: {
38
+ isPending: true,
39
+ originalTokenCount: null,
40
+ newTokenCount: null,
41
+ compressionStatus: null,
42
+ },
43
+ };
44
+ await compressCommand.action!(context, '');
45
+ await new Promise((r) => setTimeout(r, 0));
46
+ expect(context.ui.addItem).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ type: MessageType.ERROR,
49
+ text: 'Already compressing, wait for previous request to complete',
50
+ }),
51
+ expect.any(Number),
52
+ );
53
+ expect(context.ui.setPendingItem).not.toHaveBeenCalled();
54
+ expect(mockTryCompressChat).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('should set pending item, call tryCompressChat, and add result on success', async () => {
58
+ const compressedResult: ChatCompressionInfo = {
59
+ originalTokenCount: 200,
60
+ compressionStatus: CompressionStatus.COMPRESSED,
61
+ newTokenCount: 100,
62
+ };
63
+ mockTryCompressChat.mockResolvedValue(compressedResult);
64
+
65
+ await compressCommand.action!(context, '');
66
+ await new Promise((r) => setTimeout(r, 0));
67
+
68
+ expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
69
+ type: MessageType.COMPRESSION,
70
+ compression: {
71
+ isPending: true,
72
+ compressionStatus: null,
73
+ originalTokenCount: null,
74
+ newTokenCount: null,
75
+ },
76
+ });
77
+
78
+ expect(mockTryCompressChat).toHaveBeenCalledWith(
79
+ expect.stringMatching(/^compress-\d+$/),
80
+ true,
81
+ );
82
+
83
+ expect(context.ui.addItem).toHaveBeenCalledWith(
84
+ {
85
+ type: MessageType.COMPRESSION,
86
+ compression: {
87
+ isPending: false,
88
+ compressionStatus: CompressionStatus.COMPRESSED,
89
+ originalTokenCount: 200,
90
+ newTokenCount: 100,
91
+ },
92
+ },
93
+ expect.any(Number),
94
+ );
95
+
96
+ expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null);
97
+ });
98
+
99
+ it('should add an error message if tryCompressChat returns falsy', async () => {
100
+ mockTryCompressChat.mockResolvedValue(null);
101
+
102
+ await compressCommand.action!(context, '');
103
+ await new Promise((r) => setTimeout(r, 0));
104
+
105
+ expect(context.ui.addItem).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ type: MessageType.ERROR,
108
+ text: 'Failed to compress chat history.',
109
+ }),
110
+ expect.any(Number),
111
+ );
112
+ expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
113
+ });
114
+
115
+ it('should add an error message if tryCompressChat throws', async () => {
116
+ const error = new Error('Compression failed');
117
+ mockTryCompressChat.mockRejectedValue(error);
118
+
119
+ await compressCommand.action!(context, '');
120
+ await new Promise((r) => setTimeout(r, 0));
121
+
122
+ expect(context.ui.addItem).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ type: MessageType.ERROR,
125
+ text: `Failed to compress chat history: ${error.message}`,
126
+ }),
127
+ expect.any(Number),
128
+ );
129
+ expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
130
+ });
131
+
132
+ it('should clear the pending item in a finally block', async () => {
133
+ mockTryCompressChat.mockRejectedValue(new Error('some error'));
134
+ await compressCommand.action!(context, '');
135
+ await new Promise((r) => setTimeout(r, 0));
136
+ expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
137
+ });
138
+
139
+ describe('metadata', () => {
140
+ it('should have the correct name and aliases', () => {
141
+ expect(compressCommand.name).toBe('compress');
142
+ expect(compressCommand.altNames).toContain('summarize');
143
+ expect(compressCommand.altNames).toContain('compact');
144
+ });
145
+ });
146
+ });
packages/cli/src/ui/commands/compressCommand.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MessageType, type HistoryItemCompression } from '../types.js';
8
+ import { CommandKind, type SlashCommand } from './types.js';
9
+
10
+ export const compressCommand: SlashCommand = {
11
+ name: 'compress',
12
+ altNames: ['summarize', 'compact'],
13
+ description: 'Compresses the context by replacing it with a summary',
14
+ kind: CommandKind.BUILT_IN,
15
+ autoExecute: true,
16
+ action: async (context) => {
17
+ const { ui } = context;
18
+ if (ui.pendingItem) {
19
+ ui.addItem(
20
+ {
21
+ type: MessageType.ERROR,
22
+ text: 'Already compressing, wait for previous request to complete',
23
+ },
24
+ Date.now(),
25
+ );
26
+ return;
27
+ }
28
+
29
+ const pendingMessage: HistoryItemCompression = {
30
+ type: MessageType.COMPRESSION,
31
+ compression: {
32
+ isPending: true,
33
+ originalTokenCount: null,
34
+ newTokenCount: null,
35
+ compressionStatus: null,
36
+ },
37
+ };
38
+
39
+ ui.setPendingItem(pendingMessage);
40
+
41
+ void (async () => {
42
+ try {
43
+ const promptId = `compress-${Date.now()}`;
44
+ const compressed =
45
+ await context.services.agentContext?.geminiClient?.tryCompressChat(
46
+ promptId,
47
+ true,
48
+ );
49
+ if (compressed) {
50
+ ui.addItem(
51
+ {
52
+ type: MessageType.COMPRESSION,
53
+ compression: {
54
+ isPending: false,
55
+ originalTokenCount: compressed.originalTokenCount,
56
+ newTokenCount: compressed.newTokenCount,
57
+ compressionStatus: compressed.compressionStatus,
58
+ },
59
+ } as HistoryItemCompression,
60
+ Date.now(),
61
+ );
62
+ } else {
63
+ ui.addItem(
64
+ {
65
+ type: MessageType.ERROR,
66
+ text: 'Failed to compress chat history.',
67
+ },
68
+ Date.now(),
69
+ );
70
+ }
71
+ } catch (e) {
72
+ ui.addItem(
73
+ {
74
+ type: MessageType.ERROR,
75
+ text: `Failed to compress chat history: ${
76
+ e instanceof Error ? e.message : String(e)
77
+ }`,
78
+ },
79
+ Date.now(),
80
+ );
81
+ } finally {
82
+ ui.setPendingItem(null);
83
+ }
84
+ })();
85
+ },
86
+ };
packages/cli/src/ui/commands/copyCommand.test.ts ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
8
+ import { copyCommand } from './copyCommand.js';
9
+ import { type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+ import { copyToClipboard } from '../utils/commandUtils.js';
12
+
13
+ vi.mock('../utils/commandUtils.js', () => ({
14
+ copyToClipboard: vi.fn(),
15
+ }));
16
+
17
+ describe('copyCommand', () => {
18
+ let mockContext: CommandContext;
19
+ let mockCopyToClipboard: Mock;
20
+ let mockGetChat: Mock;
21
+ let mockGetHistory: Mock;
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+
26
+ mockCopyToClipboard = vi.mocked(copyToClipboard);
27
+ mockGetChat = vi.fn();
28
+ mockGetHistory = vi.fn();
29
+
30
+ mockContext = createMockCommandContext({
31
+ services: {
32
+ agentContext: {
33
+ geminiClient: {
34
+ getChat: mockGetChat,
35
+ },
36
+ },
37
+ },
38
+ });
39
+
40
+ mockGetChat.mockReturnValue({
41
+ getHistory: mockGetHistory,
42
+ });
43
+ });
44
+
45
+ it('should return info message when no history is available', async () => {
46
+ if (!copyCommand.action) throw new Error('Command has no action');
47
+
48
+ mockGetChat.mockReturnValue(undefined);
49
+
50
+ const result = await copyCommand.action(mockContext, '');
51
+
52
+ expect(result).toEqual({
53
+ type: 'message',
54
+ messageType: 'info',
55
+ content: 'No output in history',
56
+ });
57
+
58
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('should return info message when history is empty', async () => {
62
+ if (!copyCommand.action) throw new Error('Command has no action');
63
+
64
+ mockGetHistory.mockReturnValue([]);
65
+
66
+ const result = await copyCommand.action(mockContext, '');
67
+
68
+ expect(result).toEqual({
69
+ type: 'message',
70
+ messageType: 'info',
71
+ content: 'No output in history',
72
+ });
73
+
74
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('should return info message when no AI messages are found in history', async () => {
78
+ if (!copyCommand.action) throw new Error('Command has no action');
79
+
80
+ const historyWithUserOnly = [
81
+ {
82
+ role: 'user',
83
+ parts: [{ text: 'Hello' }],
84
+ },
85
+ ];
86
+
87
+ mockGetHistory.mockReturnValue(historyWithUserOnly);
88
+
89
+ const result = await copyCommand.action(mockContext, '');
90
+
91
+ expect(result).toEqual({
92
+ type: 'message',
93
+ messageType: 'info',
94
+ content: 'No output in history',
95
+ });
96
+
97
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('should copy last AI message to clipboard successfully', async () => {
101
+ if (!copyCommand.action) throw new Error('Command has no action');
102
+
103
+ const historyWithAiMessage = [
104
+ {
105
+ role: 'user',
106
+ parts: [{ text: 'Hello' }],
107
+ },
108
+ {
109
+ role: 'model',
110
+ parts: [{ text: 'Hi there! How can I help you?' }],
111
+ },
112
+ ];
113
+
114
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
115
+ mockCopyToClipboard.mockResolvedValue(undefined);
116
+
117
+ const result = await copyCommand.action(mockContext, '');
118
+
119
+ expect(result).toEqual({
120
+ type: 'message',
121
+ messageType: 'info',
122
+ content: 'Last output copied to the clipboard',
123
+ });
124
+
125
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
126
+ 'Hi there! How can I help you?',
127
+ expect.anything(),
128
+ );
129
+ });
130
+
131
+ it('should handle multiple text parts in AI message', async () => {
132
+ if (!copyCommand.action) throw new Error('Command has no action');
133
+
134
+ const historyWithMultipleParts = [
135
+ {
136
+ role: 'model',
137
+ parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }],
138
+ },
139
+ ];
140
+
141
+ mockGetHistory.mockReturnValue(historyWithMultipleParts);
142
+ mockCopyToClipboard.mockResolvedValue(undefined);
143
+
144
+ const result = await copyCommand.action(mockContext, '');
145
+
146
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
147
+ 'Part 1: Part 2: Part 3',
148
+ expect.anything(),
149
+ );
150
+ expect(result).toEqual({
151
+ type: 'message',
152
+ messageType: 'info',
153
+ content: 'Last output copied to the clipboard',
154
+ });
155
+ });
156
+
157
+ it('should filter out non-text parts', async () => {
158
+ if (!copyCommand.action) throw new Error('Command has no action');
159
+
160
+ const historyWithMixedParts = [
161
+ {
162
+ role: 'model',
163
+ parts: [
164
+ { text: 'Text part' },
165
+ { image: 'base64data' }, // Non-text part
166
+ { text: ' more text' },
167
+ ],
168
+ },
169
+ ];
170
+
171
+ mockGetHistory.mockReturnValue(historyWithMixedParts);
172
+ mockCopyToClipboard.mockResolvedValue(undefined);
173
+
174
+ const result = await copyCommand.action(mockContext, '');
175
+
176
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
177
+ 'Text part more text',
178
+ expect.anything(),
179
+ );
180
+ expect(result).toEqual({
181
+ type: 'message',
182
+ messageType: 'info',
183
+ content: 'Last output copied to the clipboard',
184
+ });
185
+ });
186
+
187
+ it('should get the last AI message when multiple AI messages exist', async () => {
188
+ if (!copyCommand.action) throw new Error('Command has no action');
189
+
190
+ const historyWithMultipleAiMessages = [
191
+ {
192
+ role: 'model',
193
+ parts: [{ text: 'First AI response' }],
194
+ },
195
+ {
196
+ role: 'user',
197
+ parts: [{ text: 'User message' }],
198
+ },
199
+ {
200
+ role: 'model',
201
+ parts: [{ text: 'Second AI response' }],
202
+ },
203
+ ];
204
+
205
+ mockGetHistory.mockReturnValue(historyWithMultipleAiMessages);
206
+ mockCopyToClipboard.mockResolvedValue(undefined);
207
+
208
+ const result = await copyCommand.action(mockContext, '');
209
+
210
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
211
+ 'Second AI response',
212
+ expect.anything(),
213
+ );
214
+ expect(result).toEqual({
215
+ type: 'message',
216
+ messageType: 'info',
217
+ content: 'Last output copied to the clipboard',
218
+ });
219
+ });
220
+
221
+ it('should handle clipboard copy error', async () => {
222
+ if (!copyCommand.action) throw new Error('Command has no action');
223
+
224
+ const historyWithAiMessage = [
225
+ {
226
+ role: 'model',
227
+ parts: [{ text: 'AI response' }],
228
+ },
229
+ ];
230
+
231
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
232
+ const clipboardError = new Error('Clipboard access denied');
233
+ mockCopyToClipboard.mockRejectedValue(clipboardError);
234
+
235
+ const result = await copyCommand.action(mockContext, '');
236
+
237
+ expect(result).toEqual({
238
+ type: 'message',
239
+ messageType: 'error',
240
+ content: `Failed to copy to the clipboard. ${clipboardError.message}`,
241
+ });
242
+
243
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
244
+ 'AI response',
245
+ expect.anything(),
246
+ );
247
+ });
248
+
249
+ it('should handle non-Error clipboard errors', async () => {
250
+ if (!copyCommand.action) throw new Error('Command has no action');
251
+
252
+ const historyWithAiMessage = [
253
+ {
254
+ role: 'model',
255
+ parts: [{ text: 'AI response' }],
256
+ },
257
+ ];
258
+
259
+ mockGetHistory.mockReturnValue(historyWithAiMessage);
260
+ const rejectedValue = 'String error';
261
+ mockCopyToClipboard.mockRejectedValue(rejectedValue);
262
+
263
+ const result = await copyCommand.action(mockContext, '');
264
+
265
+ expect(result).toEqual({
266
+ type: 'message',
267
+ messageType: 'error',
268
+ content: `Failed to copy to the clipboard. ${rejectedValue}`,
269
+ });
270
+
271
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(
272
+ 'AI response',
273
+ expect.anything(),
274
+ );
275
+ });
276
+
277
+ it('should return info message when no text parts found in AI message', async () => {
278
+ if (!copyCommand.action) throw new Error('Command has no action');
279
+
280
+ const historyWithEmptyParts = [
281
+ {
282
+ role: 'model',
283
+ parts: [{ image: 'base64data' }], // No text parts
284
+ },
285
+ ];
286
+
287
+ mockGetHistory.mockReturnValue(historyWithEmptyParts);
288
+
289
+ const result = await copyCommand.action(mockContext, '');
290
+
291
+ expect(result).toEqual({
292
+ type: 'message',
293
+ messageType: 'info',
294
+ content: 'Last AI output contains no text to copy.',
295
+ });
296
+
297
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('should handle unavailable config service', async () => {
301
+ if (!copyCommand.action) throw new Error('Command has no action');
302
+
303
+ const nullConfigContext = createMockCommandContext({
304
+ services: { agentContext: null },
305
+ });
306
+
307
+ const result = await copyCommand.action(nullConfigContext, '');
308
+
309
+ expect(result).toEqual({
310
+ type: 'message',
311
+ messageType: 'info',
312
+ content: 'No output in history',
313
+ });
314
+
315
+ expect(mockCopyToClipboard).not.toHaveBeenCalled();
316
+ });
317
+ });
packages/cli/src/ui/commands/copyCommand.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { debugLogger } from '@google/gemini-cli-core';
8
+ import { copyToClipboard } from '../utils/commandUtils.js';
9
+ import {
10
+ CommandKind,
11
+ type SlashCommand,
12
+ type SlashCommandActionReturn,
13
+ } from './types.js';
14
+
15
+ export const copyCommand: SlashCommand = {
16
+ name: 'copy',
17
+ description: 'Copy the last result or code snippet to clipboard',
18
+ kind: CommandKind.BUILT_IN,
19
+ autoExecute: true,
20
+ action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
21
+ const chat = context.services.agentContext?.geminiClient?.getChat();
22
+ const history = chat?.getHistory();
23
+
24
+ // Get the last message from the AI (model role)
25
+ const lastAiMessage = history
26
+ ? history.filter((item) => item.role === 'model').pop()
27
+ : undefined;
28
+
29
+ if (!lastAiMessage) {
30
+ return {
31
+ type: 'message',
32
+ messageType: 'info',
33
+ content: 'No output in history',
34
+ };
35
+ }
36
+ // Extract text from the parts
37
+ const lastAiOutput = lastAiMessage.parts
38
+ ?.filter((part) => part.text)
39
+ .map((part) => part.text)
40
+ .join('');
41
+
42
+ if (lastAiOutput) {
43
+ try {
44
+ const settings = context.services.settings.merged;
45
+ await copyToClipboard(lastAiOutput, settings);
46
+
47
+ return {
48
+ type: 'message',
49
+ messageType: 'info',
50
+ content: 'Last output copied to the clipboard',
51
+ };
52
+ } catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ debugLogger.debug(message);
55
+
56
+ return {
57
+ type: 'message',
58
+ messageType: 'error',
59
+ content: `Failed to copy to the clipboard. ${message}`,
60
+ };
61
+ }
62
+ } else {
63
+ return {
64
+ type: 'message',
65
+ messageType: 'info',
66
+ content: 'Last AI output contains no text to copy.',
67
+ };
68
+ }
69
+ },
70
+ };
packages/cli/src/ui/commands/corgiCommand.test.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import { corgiCommand } from './corgiCommand.js';
9
+ import { type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+
12
+ describe('corgiCommand', () => {
13
+ let mockContext: CommandContext;
14
+
15
+ beforeEach(() => {
16
+ mockContext = createMockCommandContext();
17
+ vi.spyOn(mockContext.ui, 'toggleCorgiMode');
18
+ });
19
+
20
+ it('should call the toggleCorgiMode function on the UI context', async () => {
21
+ if (!corgiCommand.action) {
22
+ throw new Error('The corgi command must have an action.');
23
+ }
24
+
25
+ await corgiCommand.action(mockContext, '');
26
+
27
+ expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it('should have the correct name and description', () => {
31
+ expect(corgiCommand.name).toBe('corgi');
32
+ expect(corgiCommand.description).toBe('Toggles corgi mode');
33
+ });
34
+ });
packages/cli/src/ui/commands/corgiCommand.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { CommandKind, type SlashCommand } from './types.js';
8
+
9
+ export const corgiCommand: SlashCommand = {
10
+ name: 'corgi',
11
+ description: 'Toggles corgi mode',
12
+ hidden: true,
13
+ kind: CommandKind.BUILT_IN,
14
+ autoExecute: true,
15
+ action: (context, _args) => {
16
+ context.ui.toggleCorgiMode();
17
+ },
18
+ };
packages/cli/src/ui/commands/directoryCommand.test.tsx ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ vi,
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ } from 'vitest';
16
+ import { directoryCommand } from './directoryCommand.js';
17
+ import {
18
+ expandHomeDir,
19
+ getDirectorySuggestions,
20
+ } from '../utils/directoryUtils.js';
21
+ import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
22
+ import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
23
+ import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
24
+ import { MessageType } from '../types.js';
25
+ import * as os from 'node:os';
26
+ import * as path from 'node:path';
27
+ import * as fs from 'node:fs';
28
+ import * as trustedFolders from '../../config/trustedFolders.js';
29
+ import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
30
+
31
+ vi.mock('node:fs', async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import('node:fs')>();
33
+ return {
34
+ ...actual,
35
+ realpathSync: vi.fn((p) => p),
36
+ };
37
+ });
38
+
39
+ vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
40
+ const actual =
41
+ await importOriginal<typeof import('../utils/directoryUtils.js')>();
42
+ return {
43
+ ...actual,
44
+ getDirectorySuggestions: vi.fn(),
45
+ };
46
+ });
47
+
48
+ describe('directoryCommand', () => {
49
+ let mockContext: CommandContext;
50
+ let mockConfig: Config;
51
+ let mockWorkspaceContext: WorkspaceContext;
52
+ const addCommand = directoryCommand.subCommands?.find(
53
+ (c) => c.name === 'add',
54
+ );
55
+ const showCommand = directoryCommand.subCommands?.find(
56
+ (c) => c.name === 'show',
57
+ );
58
+
59
+ beforeEach(() => {
60
+ mockWorkspaceContext = {
61
+ targetDir: path.resolve('/test/dir'),
62
+ addDirectory: vi.fn(),
63
+ addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
64
+ getDirectories: vi
65
+ .fn()
66
+ .mockReturnValue([
67
+ path.resolve('/home/user/project1'),
68
+ path.resolve('/home/user/project2'),
69
+ ]),
70
+ } as unknown as WorkspaceContext;
71
+
72
+ mockConfig = {
73
+ getWorkspaceContext: () => mockWorkspaceContext,
74
+ isRestrictiveSandbox: vi.fn().mockReturnValue(false),
75
+ getGeminiClient: vi.fn().mockReturnValue({
76
+ addDirectoryContext: vi.fn(),
77
+ getChatRecordingService: vi.fn().mockReturnValue({
78
+ recordDirectories: vi.fn(),
79
+ }),
80
+ }),
81
+ getWorkingDir: () => path.resolve('/test/dir'),
82
+ shouldLoadMemoryFromIncludeDirectories: () => false,
83
+ getMemoryContextManager: vi.fn(),
84
+ getDebugMode: () => false,
85
+ getFileService: () => ({}),
86
+ getFileFilteringOptions: () => ({ ignore: [], include: [] }),
87
+ setUserMemory: vi.fn(),
88
+ setGeminiMdFileCount: vi.fn(),
89
+ get config() {
90
+ return this;
91
+ },
92
+ } as unknown as Config;
93
+
94
+ mockContext = {
95
+ services: {
96
+ agentContext: mockConfig,
97
+ settings: {
98
+ merged: {
99
+ memoryDiscoveryMaxDirs: 1000,
100
+ security: {
101
+ folderTrust: {
102
+ enabled: false,
103
+ },
104
+ },
105
+ },
106
+ },
107
+ },
108
+ ui: {
109
+ addItem: vi.fn(),
110
+ },
111
+ } as unknown as CommandContext;
112
+ });
113
+
114
+ describe('show', () => {
115
+ it('should display the list of directories', () => {
116
+ if (!showCommand?.action) throw new Error('No action');
117
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
118
+ showCommand.action(mockContext, '');
119
+ expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled();
120
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ type: MessageType.INFO,
123
+ text: `Current workspace directories:\n- ${path.resolve(
124
+ '/home/user/project1',
125
+ )}\n- ${path.resolve('/home/user/project2')}`,
126
+ }),
127
+ );
128
+ });
129
+ });
130
+
131
+ describe('add', () => {
132
+ it('should show an error in a restrictive sandbox', async () => {
133
+ if (!addCommand?.action) throw new Error('No action');
134
+ vi.mocked(mockConfig.isRestrictiveSandbox).mockReturnValue(true);
135
+ const result = await addCommand.action(mockContext, '/some/path');
136
+ expect(result).toEqual({
137
+ type: 'message',
138
+ messageType: 'error',
139
+ content:
140
+ 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
141
+ });
142
+ });
143
+
144
+ it('should show an error if no path is provided', () => {
145
+ if (!addCommand?.action) throw new Error('No action');
146
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
147
+ addCommand.action(mockContext, '');
148
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
149
+ expect.objectContaining({
150
+ type: MessageType.ERROR,
151
+ text: 'Please provide at least one path to add.',
152
+ }),
153
+ );
154
+ });
155
+
156
+ it('should call addDirectory and show a success message for a single path', async () => {
157
+ const newPath = path.resolve('/home/user/new-project');
158
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
159
+ added: [newPath],
160
+ failed: [],
161
+ });
162
+ if (!addCommand?.action) throw new Error('No action');
163
+ await addCommand.action(mockContext, newPath);
164
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
165
+ newPath,
166
+ ]);
167
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
168
+ expect.objectContaining({
169
+ type: MessageType.INFO,
170
+ text: `Successfully added directories:\n- ${newPath}`,
171
+ }),
172
+ );
173
+ });
174
+
175
+ it('should call addDirectory for each path and show a success message for multiple paths', async () => {
176
+ const newPath1 = path.resolve('/home/user/new-project1');
177
+ const newPath2 = path.resolve('/home/user/new-project2');
178
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
179
+ added: [newPath1, newPath2],
180
+ failed: [],
181
+ });
182
+ if (!addCommand?.action) throw new Error('No action');
183
+ await addCommand.action(mockContext, `${newPath1},${newPath2}`);
184
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
185
+ newPath1,
186
+ newPath2,
187
+ ]);
188
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ type: MessageType.INFO,
191
+ text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`,
192
+ }),
193
+ );
194
+ });
195
+
196
+ it('should show an error if addDirectory throws an exception', async () => {
197
+ const error = new Error('Directory does not exist');
198
+ const newPath = path.resolve('/home/user/invalid-project');
199
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
200
+ added: [],
201
+ failed: [{ path: newPath, error }],
202
+ });
203
+ if (!addCommand?.action) throw new Error('No action');
204
+ await addCommand.action(mockContext, newPath);
205
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ type: MessageType.ERROR,
208
+ text: `Error adding '${newPath}': ${error.message}`,
209
+ }),
210
+ );
211
+ });
212
+
213
+ it('should add directory directly when folder trust is disabled', async () => {
214
+ if (!addCommand?.action) throw new Error('No action');
215
+ vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
216
+ const newPath = path.resolve('/home/user/new-project');
217
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
218
+ added: [newPath],
219
+ failed: [],
220
+ });
221
+
222
+ await addCommand.action(mockContext, newPath);
223
+
224
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
225
+ newPath,
226
+ ]);
227
+ });
228
+
229
+ it('should show an info message for an already added directory', async () => {
230
+ const existingPath = path.resolve('/home/user/project1');
231
+ if (!addCommand?.action) throw new Error('No action');
232
+ await addCommand.action(mockContext, existingPath);
233
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ type: MessageType.INFO,
236
+ text: `The following directories are already in the workspace:\n- ${existingPath}`,
237
+ }),
238
+ );
239
+ expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith(
240
+ existingPath,
241
+ );
242
+ });
243
+
244
+ it('should show an info message for an already added directory specified as a relative path', async () => {
245
+ const existingPath = path.resolve('/home/user/project1');
246
+ const relativePath = './project1';
247
+ const absoluteRelativePath = path.resolve(
248
+ path.resolve('/test/dir'),
249
+ relativePath,
250
+ );
251
+
252
+ vi.mocked(fs.realpathSync).mockImplementation((p) => {
253
+ if (p === absoluteRelativePath) return existingPath;
254
+ return p as string;
255
+ });
256
+
257
+ if (!addCommand?.action) throw new Error('No action');
258
+ await addCommand.action(mockContext, relativePath);
259
+
260
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
261
+ expect.objectContaining({
262
+ type: MessageType.INFO,
263
+ text: `The following directories are already in the workspace:\n- ${relativePath}`,
264
+ }),
265
+ );
266
+ });
267
+
268
+ it('should handle a mix of successful and failed additions', async () => {
269
+ const validPath = path.resolve('/home/user/valid-project');
270
+ const invalidPath = path.resolve('/home/user/invalid-project');
271
+ const error = new Error('Directory does not exist');
272
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
273
+ added: [validPath],
274
+ failed: [{ path: invalidPath, error }],
275
+ });
276
+
277
+ if (!addCommand?.action) throw new Error('No action');
278
+ await addCommand.action(mockContext, `${validPath},${invalidPath}`);
279
+
280
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
281
+ expect.objectContaining({
282
+ type: MessageType.INFO,
283
+ text: `Successfully added directories:\n- ${validPath}`,
284
+ }),
285
+ );
286
+
287
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
288
+ expect.objectContaining({
289
+ type: MessageType.ERROR,
290
+ text: `Error adding '${invalidPath}': ${error.message}`,
291
+ }),
292
+ );
293
+ });
294
+
295
+ describe('completion', () => {
296
+ const completion = addCommand!.completion!;
297
+
298
+ it('should return empty suggestions for an empty path', async () => {
299
+ const results = await completion(mockContext, '');
300
+ expect(results).toEqual([]);
301
+ });
302
+
303
+ it('should return empty suggestions for whitespace only path', async () => {
304
+ const results = await completion(mockContext, ' ');
305
+ expect(results).toEqual([]);
306
+ });
307
+
308
+ it('should return suggestions for a single path', async () => {
309
+ vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']);
310
+
311
+ const results = await completion(mockContext, 'd');
312
+
313
+ expect(getDirectorySuggestions).toHaveBeenCalledWith('d');
314
+ expect(results).toEqual(['docs/', 'src/']);
315
+ });
316
+
317
+ it('should return suggestions for multiple paths', async () => {
318
+ vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
319
+
320
+ const results = await completion(mockContext, 'docs/,s');
321
+
322
+ expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
323
+ expect(results).toEqual(['docs/,src/']);
324
+ });
325
+
326
+ it('should handle leading whitespace in suggestions', async () => {
327
+ vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
328
+
329
+ const results = await completion(mockContext, 'docs/, s');
330
+
331
+ expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
332
+ expect(results).toEqual(['docs/, src/']);
333
+ });
334
+
335
+ it('should filter out existing directories from suggestions', async () => {
336
+ const existingPath = path.resolve(process.cwd(), 'existing');
337
+ vi.mocked(mockWorkspaceContext.getDirectories).mockReturnValue([
338
+ existingPath,
339
+ ]);
340
+ vi.mocked(getDirectorySuggestions).mockResolvedValue([
341
+ 'existing/',
342
+ 'new/',
343
+ ]);
344
+
345
+ const results = await completion(mockContext, 'ex');
346
+
347
+ expect(results).toEqual(['new/']);
348
+ });
349
+ });
350
+ });
351
+
352
+ describe('add with folder trust enabled', () => {
353
+ let mockIsPathTrusted: Mock;
354
+
355
+ beforeEach(() => {
356
+ vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true);
357
+ // isWorkspaceTrusted is no longer checked, so we don't need to mock it returning true
358
+ mockIsPathTrusted = vi.fn();
359
+ const mockLoadedFolders = {
360
+ isPathTrusted: mockIsPathTrusted,
361
+ } as unknown as LoadedTrustedFolders;
362
+ vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue(
363
+ mockLoadedFolders,
364
+ );
365
+ });
366
+
367
+ afterEach(() => {
368
+ vi.restoreAllMocks();
369
+ });
370
+
371
+ it('should add a trusted directory', async () => {
372
+ if (!addCommand?.action) throw new Error('No action');
373
+ mockIsPathTrusted.mockReturnValue(true);
374
+ const newPath = path.resolve('/home/user/trusted-project');
375
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
376
+ added: [newPath],
377
+ failed: [],
378
+ });
379
+
380
+ await addCommand.action(mockContext, newPath);
381
+
382
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
383
+ newPath,
384
+ ]);
385
+ });
386
+
387
+ it('should return a custom dialog for an explicitly untrusted directory (upgrade flow)', async () => {
388
+ if (!addCommand?.action) throw new Error('No action');
389
+ mockIsPathTrusted.mockReturnValue(false); // DO_NOT_TRUST
390
+ const newPath = path.resolve('/home/user/untrusted-project');
391
+
392
+ const result = await addCommand.action(mockContext, newPath);
393
+
394
+ expect(result).toEqual(
395
+ expect.objectContaining({
396
+ type: 'custom_dialog',
397
+ component: expect.objectContaining({
398
+ type: expect.any(Function), // React component for MultiFolderTrustDialog
399
+ }),
400
+ }),
401
+ );
402
+ if (!result) {
403
+ throw new Error('Command did not return a result');
404
+ }
405
+ const component = (result as OpenCustomDialogActionReturn)
406
+ .component as React.ReactElement<MultiFolderTrustDialogProps>;
407
+ expect(component.props.folders.includes(newPath)).toBeTruthy();
408
+ });
409
+
410
+ it('should return a custom dialog for a directory with undefined trust', async () => {
411
+ if (!addCommand?.action) throw new Error('No action');
412
+ mockIsPathTrusted.mockReturnValue(undefined);
413
+ const newPath = path.resolve('/home/user/undefined-trust-project');
414
+
415
+ const result = await addCommand.action(mockContext, newPath);
416
+
417
+ expect(result).toEqual(
418
+ expect.objectContaining({
419
+ type: 'custom_dialog',
420
+ component: expect.objectContaining({
421
+ type: expect.any(Function), // React component for MultiFolderTrustDialog
422
+ }),
423
+ }),
424
+ );
425
+ if (!result) {
426
+ throw new Error('Command did not return a result');
427
+ }
428
+ const component = (result as OpenCustomDialogActionReturn)
429
+ .component as React.ReactElement<MultiFolderTrustDialogProps>;
430
+ expect(component.props.folders.includes(newPath)).toBeTruthy();
431
+ });
432
+
433
+ it('should prompt for directory even if workspace is untrusted', async () => {
434
+ if (!addCommand?.action) throw new Error('No action');
435
+ // Even if workspace is untrusted, we should still check directory trust
436
+ vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
437
+ isTrusted: false,
438
+ source: 'file',
439
+ });
440
+ mockIsPathTrusted.mockReturnValue(undefined);
441
+ const newPath = path.resolve('/home/user/new-project');
442
+
443
+ const result = await addCommand.action(mockContext, newPath);
444
+
445
+ expect(result).toEqual(
446
+ expect.objectContaining({
447
+ type: 'custom_dialog',
448
+ }),
449
+ );
450
+ });
451
+ });
452
+
453
+ it('should correctly expand a Windows-style home directory path', () => {
454
+ const windowsPath = '%userprofile%\\Documents';
455
+ const expectedPath = path.win32.join(os.homedir(), 'Documents');
456
+ const result = expandHomeDir(windowsPath);
457
+ expect(path.win32.normalize(result)).toBe(
458
+ path.win32.normalize(expectedPath),
459
+ );
460
+ });
461
+ });
packages/cli/src/ui/commands/directoryCommand.tsx ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ isFolderTrustEnabled,
9
+ loadTrustedFolders,
10
+ } from '../../config/trustedFolders.js';
11
+ import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
12
+ import {
13
+ CommandKind,
14
+ type SlashCommand,
15
+ type CommandContext,
16
+ } from './types.js';
17
+ import { MessageType, type HistoryItem } from '../types.js';
18
+ import { type Config } from '@google/gemini-cli-core';
19
+ import {
20
+ expandHomeDir,
21
+ getDirectorySuggestions,
22
+ batchAddDirectories,
23
+ } from '../utils/directoryUtils.js';
24
+ import * as path from 'node:path';
25
+ import * as fs from 'node:fs';
26
+
27
+ async function finishAddingDirectories(
28
+ config: Config,
29
+ addItem: (
30
+ itemData: Omit<HistoryItem, 'id'>,
31
+ baseTimestamp?: number,
32
+ ) => number,
33
+ added: string[],
34
+ errors: string[],
35
+ ) {
36
+ if (!config) {
37
+ addItem({
38
+ type: MessageType.ERROR,
39
+ text: 'Configuration is not available.',
40
+ });
41
+ return;
42
+ }
43
+
44
+ if (added.length > 0) {
45
+ try {
46
+ if (config.shouldLoadMemoryFromIncludeDirectories()) {
47
+ await config.getMemoryContextManager()?.refresh();
48
+ }
49
+ addItem({
50
+ type: MessageType.INFO,
51
+ text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
52
+ });
53
+ } catch (error) {
54
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
55
+ errors.push(`Error refreshing memory: ${(error as Error).message}`);
56
+ }
57
+ }
58
+
59
+ if (added.length > 0) {
60
+ const gemini = config.geminiClient;
61
+ if (gemini) {
62
+ await gemini.addDirectoryContext();
63
+
64
+ // Persist directories to session file for resume support
65
+ const chatRecordingService = gemini.getChatRecordingService();
66
+ const workspaceContext = config.getWorkspaceContext();
67
+ chatRecordingService?.recordDirectories(
68
+ workspaceContext.getDirectories(),
69
+ );
70
+ }
71
+ addItem({
72
+ type: MessageType.INFO,
73
+ text: `Successfully added directories:\n- ${added.join('\n- ')}`,
74
+ });
75
+ }
76
+
77
+ if (errors.length > 0) {
78
+ addItem({ type: MessageType.ERROR, text: errors.join('\n') });
79
+ }
80
+ }
81
+
82
+ export const directoryCommand: SlashCommand = {
83
+ name: 'directory',
84
+ altNames: ['dir'],
85
+ description: 'Manage workspace directories',
86
+ kind: CommandKind.BUILT_IN,
87
+ subCommands: [
88
+ {
89
+ name: 'add',
90
+ description:
91
+ 'Add directories to the workspace. Use comma to separate multiple paths',
92
+ kind: CommandKind.BUILT_IN,
93
+ autoExecute: false,
94
+ showCompletionLoading: false,
95
+ completion: async (context: CommandContext, partialArg: string) => {
96
+ // Support multiple paths separated by commas
97
+ const parts = partialArg.split(',');
98
+ const lastPart = parts[parts.length - 1];
99
+ const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? '';
100
+ const trimmedLastPart = lastPart.trimStart();
101
+
102
+ if (trimmedLastPart === '') {
103
+ return [];
104
+ }
105
+
106
+ const suggestions = await getDirectorySuggestions(trimmedLastPart);
107
+
108
+ // Filter out existing directories
109
+ let filteredSuggestions = suggestions;
110
+ if (context.services.agentContext?.config) {
111
+ const workspaceContext =
112
+ context.services.agentContext.config.getWorkspaceContext();
113
+ const existingDirs = new Set(
114
+ workspaceContext.getDirectories().map((dir) => path.resolve(dir)),
115
+ );
116
+
117
+ filteredSuggestions = suggestions.filter((s) => {
118
+ const expanded = expandHomeDir(s);
119
+ const absolute = path.resolve(expanded);
120
+
121
+ if (existingDirs.has(absolute)) {
122
+ return false;
123
+ }
124
+ if (
125
+ absolute.endsWith(path.sep) &&
126
+ existingDirs.has(absolute.slice(0, -1))
127
+ ) {
128
+ return false;
129
+ }
130
+ return true;
131
+ });
132
+ }
133
+
134
+ if (parts.length > 1) {
135
+ const prefix = parts.slice(0, -1).join(',') + ',';
136
+ return filteredSuggestions.map((s) => prefix + leadingWhitespace + s);
137
+ }
138
+
139
+ return filteredSuggestions.map((s) => leadingWhitespace + s);
140
+ },
141
+ action: async (context: CommandContext, args: string) => {
142
+ const {
143
+ ui: { addItem },
144
+ services: { agentContext, settings },
145
+ } = context;
146
+ const [...rest] = args.split(' ');
147
+
148
+ if (!agentContext) {
149
+ addItem({
150
+ type: MessageType.ERROR,
151
+ text: 'Configuration is not available.',
152
+ });
153
+ return;
154
+ }
155
+
156
+ if (agentContext.config.isRestrictiveSandbox()) {
157
+ return {
158
+ type: 'message' as const,
159
+ messageType: 'error' as const,
160
+ content:
161
+ 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
162
+ };
163
+ }
164
+
165
+ const pathsToAdd = rest
166
+ .join(' ')
167
+ .split(',')
168
+ .filter((p) => p);
169
+ if (pathsToAdd.length === 0) {
170
+ addItem({
171
+ type: MessageType.ERROR,
172
+ text: 'Please provide at least one path to add.',
173
+ });
174
+ return;
175
+ }
176
+
177
+ const added: string[] = [];
178
+ const errors: string[] = [];
179
+ const alreadyAdded: string[] = [];
180
+
181
+ const workspaceContext = agentContext.config.getWorkspaceContext();
182
+ const currentWorkspaceDirs = workspaceContext.getDirectories();
183
+ const pathsToProcess: string[] = [];
184
+
185
+ for (const pathToAdd of pathsToAdd) {
186
+ const trimmedPath = pathToAdd.trim();
187
+ const expandedPath = expandHomeDir(trimmedPath);
188
+ try {
189
+ const absolutePath = path.resolve(
190
+ workspaceContext.targetDir,
191
+ expandedPath,
192
+ );
193
+ const resolvedPath = fs.realpathSync(absolutePath);
194
+ if (currentWorkspaceDirs.includes(resolvedPath)) {
195
+ alreadyAdded.push(trimmedPath);
196
+ continue;
197
+ }
198
+ } catch {
199
+ // Path might not exist or be inaccessible.
200
+ // We'll let batchAddDirectories handle it later.
201
+ }
202
+ pathsToProcess.push(trimmedPath);
203
+ }
204
+
205
+ if (alreadyAdded.length > 0) {
206
+ addItem({
207
+ type: MessageType.INFO,
208
+ text: `The following directories are already in the workspace:\n- ${alreadyAdded.join(
209
+ '\n- ',
210
+ )}`,
211
+ });
212
+ }
213
+
214
+ if (pathsToProcess.length === 0) {
215
+ return;
216
+ }
217
+
218
+ if (isFolderTrustEnabled(settings.merged)) {
219
+ const trustedFolders = loadTrustedFolders();
220
+ const dirsToConfirm: string[] = [];
221
+ const trustedDirs: string[] = [];
222
+
223
+ for (const pathToAdd of pathsToProcess) {
224
+ const expandedPath = path.resolve(expandHomeDir(pathToAdd.trim()));
225
+ const isTrusted = trustedFolders.isPathTrusted(expandedPath);
226
+ // If explicitly trusted, add immediately.
227
+ // If undefined or explicitly untrusted (DO_NOT_TRUST), prompt for confirmation.
228
+ // This allows users to "upgrade" a DO_NOT_TRUST folder to trusted via the dialog.
229
+ if (isTrusted === true) {
230
+ trustedDirs.push(pathToAdd.trim());
231
+ } else {
232
+ dirsToConfirm.push(pathToAdd.trim());
233
+ }
234
+ }
235
+
236
+ if (trustedDirs.length > 0) {
237
+ const result = batchAddDirectories(workspaceContext, trustedDirs);
238
+ added.push(...result.added);
239
+ errors.push(...result.errors);
240
+ }
241
+
242
+ if (dirsToConfirm.length > 0) {
243
+ return {
244
+ type: 'custom_dialog',
245
+ component: (
246
+ <MultiFolderTrustDialog
247
+ folders={dirsToConfirm}
248
+ onComplete={context.ui.removeComponent}
249
+ trustedDirs={added}
250
+ errors={errors}
251
+ finishAddingDirectories={finishAddingDirectories}
252
+ config={agentContext.config}
253
+ addItem={addItem}
254
+ />
255
+ ),
256
+ };
257
+ }
258
+ } else {
259
+ const result = batchAddDirectories(workspaceContext, pathsToProcess);
260
+ added.push(...result.added);
261
+ errors.push(...result.errors);
262
+ }
263
+
264
+ await finishAddingDirectories(
265
+ agentContext.config,
266
+ addItem,
267
+ added,
268
+ errors,
269
+ );
270
+ return;
271
+ },
272
+ },
273
+ {
274
+ name: 'show',
275
+ description: 'Show all directories in the workspace',
276
+ kind: CommandKind.BUILT_IN,
277
+ action: async (context: CommandContext) => {
278
+ const {
279
+ ui: { addItem },
280
+ services: { agentContext },
281
+ } = context;
282
+ if (!agentContext) {
283
+ addItem({
284
+ type: MessageType.ERROR,
285
+ text: 'Configuration is not available.',
286
+ });
287
+ return;
288
+ }
289
+ const workspaceContext = agentContext.config.getWorkspaceContext();
290
+ const directories = workspaceContext.getDirectories();
291
+ const directoryList = directories.map((dir) => `- ${dir}`).join('\n');
292
+ addItem({
293
+ type: MessageType.INFO,
294
+ text: `Current workspace directories:\n${directoryList}`,
295
+ });
296
+ },
297
+ },
298
+ ],
299
+ };
packages/cli/src/ui/commands/docsCommand.test.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import open from 'open';
9
+ import { docsCommand } from './docsCommand.js';
10
+ import { type CommandContext } from './types.js';
11
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
12
+ import { MessageType } from '../types.js';
13
+
14
+ // Mock the 'open' library
15
+ vi.mock('open', () => ({
16
+ default: vi.fn(),
17
+ }));
18
+
19
+ describe('docsCommand', () => {
20
+ let mockContext: CommandContext;
21
+ beforeEach(() => {
22
+ // Create a fresh mock context before each test
23
+ mockContext = createMockCommandContext();
24
+ // Reset the `open` mock
25
+ vi.mocked(open).mockClear();
26
+ });
27
+
28
+ afterEach(() => {
29
+ // Restore any stubbed environment variables
30
+ vi.unstubAllEnvs();
31
+ });
32
+
33
+ it("should add an info message and call 'open' in a non-sandbox environment", async () => {
34
+ if (!docsCommand.action) {
35
+ throw new Error('docsCommand must have an action.');
36
+ }
37
+
38
+ const docsUrl = 'https://goo.gle/gemini-cli-docs';
39
+
40
+ await docsCommand.action(mockContext, '');
41
+
42
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
43
+ {
44
+ type: MessageType.INFO,
45
+ text: `Opening documentation in your browser: ${docsUrl}`,
46
+ },
47
+ expect.any(Number),
48
+ );
49
+
50
+ expect(open).toHaveBeenCalledWith(docsUrl);
51
+ });
52
+
53
+ it('should only add an info message in a sandbox environment', async () => {
54
+ if (!docsCommand.action) {
55
+ throw new Error('docsCommand must have an action.');
56
+ }
57
+
58
+ // Simulate a sandbox environment
59
+ vi.stubEnv('SANDBOX', 'gemini-sandbox');
60
+ const docsUrl = 'https://goo.gle/gemini-cli-docs';
61
+
62
+ await docsCommand.action(mockContext, '');
63
+
64
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
65
+ {
66
+ type: MessageType.INFO,
67
+ text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
68
+ },
69
+ expect.any(Number),
70
+ );
71
+
72
+ // Ensure 'open' was not called in the sandbox
73
+ expect(open).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("should not open browser for 'sandbox-exec'", async () => {
77
+ if (!docsCommand.action) {
78
+ throw new Error('docsCommand must have an action.');
79
+ }
80
+
81
+ // Simulate the specific 'sandbox-exec' environment
82
+ vi.stubEnv('SANDBOX', 'sandbox-exec');
83
+ const docsUrl = 'https://goo.gle/gemini-cli-docs';
84
+
85
+ await docsCommand.action(mockContext, '');
86
+
87
+ // The logic should fall through to the 'else' block
88
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
89
+ {
90
+ type: MessageType.INFO,
91
+ text: `Opening documentation in your browser: ${docsUrl}`,
92
+ },
93
+ expect.any(Number),
94
+ );
95
+
96
+ // 'open' should be called in this specific sandbox case
97
+ expect(open).toHaveBeenCalledWith(docsUrl);
98
+ });
99
+ });
packages/cli/src/ui/commands/docsCommand.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import open from 'open';
8
+ import process from 'node:process';
9
+ import {
10
+ type CommandContext,
11
+ type SlashCommand,
12
+ CommandKind,
13
+ } from './types.js';
14
+ import { MessageType } from '../types.js';
15
+
16
+ export const docsCommand: SlashCommand = {
17
+ name: 'docs',
18
+ description: 'Open full Gemini CLI documentation in your browser',
19
+ kind: CommandKind.BUILT_IN,
20
+ autoExecute: true,
21
+ action: async (context: CommandContext): Promise<void> => {
22
+ const docsUrl = 'https://goo.gle/gemini-cli-docs';
23
+
24
+ if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
25
+ context.ui.addItem(
26
+ {
27
+ type: MessageType.INFO,
28
+ text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
29
+ },
30
+ Date.now(),
31
+ );
32
+ } else {
33
+ context.ui.addItem(
34
+ {
35
+ type: MessageType.INFO,
36
+ text: `Opening documentation in your browser: ${docsUrl}`,
37
+ },
38
+ Date.now(),
39
+ );
40
+ await open(docsUrl);
41
+ }
42
+ },
43
+ };
packages/cli/src/ui/commands/editorCommand.test.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { editorCommand } from './editorCommand.js';
9
+ // 1. Import the mock context utility
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+
12
+ describe('editorCommand', () => {
13
+ it('should return a dialog action to open the editor dialog', () => {
14
+ if (!editorCommand.action) {
15
+ throw new Error('The editor command must have an action.');
16
+ }
17
+ const mockContext = createMockCommandContext();
18
+ const result = editorCommand.action(mockContext, '');
19
+
20
+ expect(result).toEqual({
21
+ type: 'dialog',
22
+ dialog: 'editor',
23
+ });
24
+ });
25
+
26
+ it('should have the correct name and description', () => {
27
+ expect(editorCommand.name).toBe('editor');
28
+ expect(editorCommand.description).toBe('Set external editor preference');
29
+ });
30
+ });
packages/cli/src/ui/commands/editorCommand.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ CommandKind,
9
+ type OpenDialogActionReturn,
10
+ type SlashCommand,
11
+ } from './types.js';
12
+
13
+ export const editorCommand: SlashCommand = {
14
+ name: 'editor',
15
+ description: 'Set external editor preference',
16
+ kind: CommandKind.BUILT_IN,
17
+ autoExecute: true,
18
+ action: (): OpenDialogActionReturn => ({
19
+ type: 'dialog',
20
+ dialog: 'editor',
21
+ }),
22
+ };
packages/cli/src/ui/commands/exportSessionCommand.test.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { exportSessionCommand } from './exportSessionCommand.js';
9
+ import * as fs from 'node:fs/promises';
10
+ import * as path from 'node:path';
11
+ import { SessionSelector } from '../../utils/sessionUtils.js';
12
+ import type { CommandContext } from './types.js';
13
+ import { Storage, type ConversationRecord } from '@google/gemini-cli-core';
14
+
15
+ vi.mock('node:fs/promises');
16
+ vi.mock('../../utils/sessionUtils.js');
17
+
18
+ describe('exportSessionCommand', () => {
19
+ let mockContext: CommandContext;
20
+
21
+ beforeEach(() => {
22
+ vi.resetAllMocks();
23
+ vi.spyOn(Storage.prototype, 'initialize').mockResolvedValue(undefined);
24
+ vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
25
+ path.join(path.sep, 'tmp', 'mock-dir'),
26
+ );
27
+ mockContext = {
28
+ services: {
29
+ agentContext: {
30
+ config: {
31
+ sessionId: 'test-session-id',
32
+ getSessionId: () => 'test-session-id',
33
+ storage: new Storage(process.cwd()),
34
+ },
35
+ },
36
+ },
37
+ invocation: {
38
+ args: ' export.json ',
39
+ name: 'export-session',
40
+ raw: '/export-session export.json',
41
+ },
42
+ ui: {
43
+ addItem: vi.fn(),
44
+ setPendingItem: vi.fn(),
45
+ pendingItem: null,
46
+ },
47
+ } as unknown as CommandContext;
48
+ });
49
+
50
+ it('should return error if no path is provided', async () => {
51
+ mockContext.invocation!.args = ' ';
52
+ const result = await exportSessionCommand.action!(mockContext, '');
53
+
54
+ expect(result).toEqual({
55
+ type: 'message',
56
+ messageType: 'error',
57
+ content: expect.stringContaining('Please provide a file path'),
58
+ });
59
+ });
60
+
61
+ it('should return error if sessionId is missing', async () => {
62
+ mockContext.services.agentContext!.config.getSessionId = () =>
63
+ undefined as unknown as string;
64
+ const result = await exportSessionCommand.action!(mockContext, '');
65
+
66
+ expect(result).toEqual({
67
+ type: 'message',
68
+ messageType: 'error',
69
+ content: 'No active session found to export.',
70
+ });
71
+ });
72
+
73
+ it('should export the session successfully', async () => {
74
+ const mockSessionData: ConversationRecord = {
75
+ sessionId: 'test-session-id',
76
+ messages: [],
77
+ projectHash: 'hash',
78
+ startTime: 'time',
79
+ lastUpdated: 'time',
80
+ };
81
+ vi.mocked(SessionSelector.prototype.resolveSession).mockResolvedValue({
82
+ sessionData: mockSessionData,
83
+ sessionPath: path.join(
84
+ path.sep,
85
+ 'tmp',
86
+ 'mock-dir',
87
+ 'chats',
88
+ 'session.jsonl',
89
+ ),
90
+ displayInfo: 'test',
91
+ });
92
+
93
+ vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
94
+
95
+ const result = await exportSessionCommand.action!(mockContext, '');
96
+
97
+ expect(result).toBeUndefined();
98
+ expect(fs.writeFile).toHaveBeenCalledWith(
99
+ path.resolve(process.cwd(), 'export.json'),
100
+ JSON.stringify(mockSessionData, null, 2),
101
+ 'utf-8',
102
+ );
103
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ type: 'export_session',
106
+ exportSession: { isPending: true },
107
+ }),
108
+ );
109
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ type: 'export_session',
112
+ exportSession: {
113
+ isPending: false,
114
+ targetPath: expect.stringContaining('export.json'),
115
+ },
116
+ }),
117
+ expect.any(Number),
118
+ );
119
+ expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
120
+ });
121
+
122
+ it('should return error if resolveSession fails', async () => {
123
+ vi.mocked(SessionSelector.prototype.resolveSession).mockRejectedValue(
124
+ new Error('Session not found'),
125
+ );
126
+
127
+ const result = await exportSessionCommand.action!(mockContext, '');
128
+
129
+ expect(result).toEqual({
130
+ type: 'message',
131
+ messageType: 'error',
132
+ content: 'Failed to export session: Session not found',
133
+ });
134
+ });
135
+ });
packages/cli/src/ui/commands/exportSessionCommand.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import {
10
+ type CommandContext,
11
+ type SlashCommand,
12
+ type SlashCommandActionReturn,
13
+ CommandKind,
14
+ } from './types.js';
15
+ import { MessageType, type HistoryItemExportSession } from '../types.js';
16
+ import { SessionSelector } from '../../utils/sessionUtils.js';
17
+
18
+ export const exportSessionCommand: SlashCommand = {
19
+ name: 'export-session',
20
+ description: 'Export the current session to a JSON file',
21
+ kind: CommandKind.BUILT_IN,
22
+ autoExecute: true,
23
+ action: async (
24
+ context: CommandContext,
25
+ ): Promise<SlashCommandActionReturn | void> => {
26
+ const { ui } = context;
27
+ const args = context.invocation?.args.trim();
28
+ if (!args) {
29
+ return {
30
+ type: 'message',
31
+ messageType: 'error',
32
+ content:
33
+ 'Please provide a file path to export the session to. Example: /export-session ./my-session.json',
34
+ };
35
+ }
36
+
37
+ const sessionId = context.services.agentContext?.config.getSessionId();
38
+ if (!sessionId) {
39
+ return {
40
+ type: 'message',
41
+ messageType: 'error',
42
+ content: 'No active session found to export.',
43
+ };
44
+ }
45
+
46
+ if (ui.pendingItem) {
47
+ ui.addItem(
48
+ {
49
+ type: MessageType.ERROR,
50
+ text: 'Operation already in progress, please wait.',
51
+ },
52
+ Date.now(),
53
+ );
54
+ return;
55
+ }
56
+
57
+ const pendingMessage: HistoryItemExportSession = {
58
+ type: MessageType.EXPORT_SESSION,
59
+ exportSession: {
60
+ isPending: true,
61
+ },
62
+ };
63
+
64
+ try {
65
+ ui.setPendingItem(pendingMessage);
66
+ const storage = context.services.agentContext!.config.storage;
67
+ const sessionSelector = new SessionSelector(storage);
68
+ const { sessionData } = await sessionSelector.resolveSession(sessionId);
69
+
70
+ const targetPath = path.resolve(process.cwd(), args);
71
+
72
+ await fs.writeFile(
73
+ targetPath,
74
+ JSON.stringify(sessionData, null, 2),
75
+ 'utf-8',
76
+ );
77
+
78
+ ui.addItem(
79
+ {
80
+ type: MessageType.EXPORT_SESSION,
81
+ exportSession: {
82
+ isPending: false,
83
+ targetPath,
84
+ },
85
+ } as HistoryItemExportSession,
86
+ Date.now(),
87
+ );
88
+ } catch (error) {
89
+ return {
90
+ type: 'message',
91
+ messageType: 'error',
92
+ content: `Failed to export session: ${error instanceof Error ? error.message : 'Unknown error'}`,
93
+ };
94
+ } finally {
95
+ ui.setPendingItem(null);
96
+ }
97
+ },
98
+ };
packages/cli/src/ui/commands/extensionsCommand.test.ts ADDED
@@ -0,0 +1,1227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { type ReactElement } from 'react';
8
+
9
+ import type {
10
+ ExtensionLoader,
11
+ GeminiCLIExtension,
12
+ } from '@google/gemini-cli-core';
13
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
14
+ import { MessageType } from '../types.js';
15
+ import {
16
+ completeExtensions,
17
+ completeExtensionsAndScopes,
18
+ extensionsCommand,
19
+ } from './extensionsCommand.js';
20
+ import {
21
+ ConfigExtensionDialog,
22
+ type ConfigExtensionDialogProps,
23
+ } from '../components/ConfigExtensionDialog.js';
24
+ import {
25
+ ExtensionRegistryView,
26
+ type ExtensionRegistryViewProps,
27
+ } from '../components/views/ExtensionRegistryView.js';
28
+ import { type CommandContext, type SlashCommand } from './types.js';
29
+
30
+ import {
31
+ describe,
32
+ it,
33
+ expect,
34
+ vi,
35
+ beforeEach,
36
+ afterEach,
37
+ type MockedFunction,
38
+ } from 'vitest';
39
+ import { type ExtensionUpdateAction } from '../state/extensions.js';
40
+ import {
41
+ ExtensionManager,
42
+ inferInstallMetadata,
43
+ } from '../../config/extension-manager.js';
44
+ import { SettingScope } from '../../config/settings.js';
45
+ import { stat } from 'node:fs/promises';
46
+ import { type RegistryExtension } from '../../config/extensionRegistryClient.js';
47
+ import { waitFor } from '../../test-utils/async.js';
48
+
49
+ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
50
+ const actual =
51
+ await importOriginal<typeof import('../../config/extension-manager.js')>();
52
+ return {
53
+ ...actual,
54
+ inferInstallMetadata: vi.fn(),
55
+ };
56
+ });
57
+
58
+ import open from 'open';
59
+ import type { Stats } from 'node:fs';
60
+
61
+ vi.mock('open', () => ({
62
+ default: vi.fn(),
63
+ }));
64
+
65
+ vi.mock('node:fs/promises', () => ({
66
+ stat: vi.fn(),
67
+ }));
68
+
69
+ vi.mock('../../config/extensions/extensionSettings.js', () => ({
70
+ ExtensionSettingScope: {
71
+ USER: 'user',
72
+ WORKSPACE: 'workspace',
73
+ },
74
+ getScopedEnvContents: vi.fn().mockResolvedValue({}),
75
+ promptForSetting: vi.fn(),
76
+ updateSetting: vi.fn(),
77
+ }));
78
+
79
+ vi.mock('prompts', () => ({
80
+ default: vi.fn(),
81
+ }));
82
+
83
+ vi.mock('../../config/extensions/update.js', () => ({
84
+ updateExtension: vi.fn(),
85
+ checkForAllExtensionUpdates: vi.fn(),
86
+ }));
87
+
88
+ const mockDisableExtension = vi.fn();
89
+ const mockEnableExtension = vi.fn();
90
+ const mockInstallExtension = vi.fn();
91
+ const mockUninstallExtension = vi.fn();
92
+ const mockGetExtensions = vi.fn();
93
+
94
+ const inactiveExt: GeminiCLIExtension = {
95
+ name: 'ext-one',
96
+ id: 'ext-one-id',
97
+ version: '1.0.0',
98
+ isActive: false, // should suggest disabled extensions
99
+ path: '/test/dir/ext-one',
100
+ contextFiles: [],
101
+ installMetadata: {
102
+ type: 'git',
103
+ autoUpdate: false,
104
+ source: 'https://github.com/some/extension.git',
105
+ },
106
+ };
107
+ const activeExt: GeminiCLIExtension = {
108
+ name: 'ext-two',
109
+ id: 'ext-two-id',
110
+ version: '1.0.0',
111
+ isActive: true, // should not suggest enabled extensions
112
+ path: '/test/dir/ext-two',
113
+ contextFiles: [],
114
+ installMetadata: {
115
+ type: 'git',
116
+ autoUpdate: false,
117
+ source: 'https://github.com/some/extension.git',
118
+ },
119
+ };
120
+ const allExt: GeminiCLIExtension = {
121
+ name: 'all-ext',
122
+ id: 'all-ext-id',
123
+ version: '1.0.0',
124
+ isActive: true,
125
+ path: '/test/dir/all-ext',
126
+ contextFiles: [],
127
+ installMetadata: {
128
+ type: 'git',
129
+ autoUpdate: false,
130
+ source: 'https://github.com/some/extension.git',
131
+ },
132
+ };
133
+
134
+ describe('extensionsCommand', () => {
135
+ let mockContext: CommandContext;
136
+ const mockDispatchExtensionState = vi.fn();
137
+ let mockExtensionLoader: unknown;
138
+ let mockReloadSkills: MockedFunction<() => Promise<void>>;
139
+ let mockReloadAgents: MockedFunction<() => Promise<void>>;
140
+
141
+ beforeEach(() => {
142
+ vi.resetAllMocks();
143
+
144
+ mockExtensionLoader = Object.create(ExtensionManager.prototype);
145
+ Object.assign(mockExtensionLoader as object, {
146
+ enableExtension: mockEnableExtension,
147
+ disableExtension: mockDisableExtension,
148
+ installOrUpdateExtension: mockInstallExtension,
149
+ uninstallExtension: mockUninstallExtension,
150
+ getExtensions: mockGetExtensions,
151
+ loadExtensionConfig: vi.fn().mockResolvedValue({
152
+ name: 'test-ext',
153
+ settings: [{ name: 'setting1', envVar: 'SETTING1' }],
154
+ }),
155
+ });
156
+
157
+ mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]);
158
+ vi.mocked(open).mockClear();
159
+ mockReloadAgents = vi.fn().mockResolvedValue(undefined);
160
+ mockReloadSkills = vi.fn().mockResolvedValue(undefined);
161
+
162
+ mockContext = createMockCommandContext({
163
+ services: {
164
+ agentContext: {
165
+ config: {
166
+ getExtensions: mockGetExtensions,
167
+ getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
168
+ getWorkingDir: () => '/test/dir',
169
+ reloadSkills: mockReloadSkills,
170
+ getAgentRegistry: vi.fn().mockReturnValue({
171
+ reload: mockReloadAgents,
172
+ }),
173
+ },
174
+ },
175
+ },
176
+ ui: {
177
+ dispatchExtensionStateUpdate: mockDispatchExtensionState,
178
+ removeComponent: vi.fn(),
179
+ },
180
+ });
181
+ });
182
+
183
+ afterEach(() => {
184
+ // Restore any stubbed environment variables, similar to docsCommand.test.ts
185
+ vi.unstubAllEnvs();
186
+ });
187
+
188
+ describe('list', () => {
189
+ it('should add an EXTENSIONS_LIST item to the UI', async () => {
190
+ const command = extensionsCommand();
191
+ if (!command.action) throw new Error('Action not defined');
192
+ await command.action(mockContext, '');
193
+
194
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
195
+ type: MessageType.EXTENSIONS_LIST,
196
+ extensions: expect.any(Array),
197
+ });
198
+ });
199
+
200
+ it('should show a message if no extensions are installed', async () => {
201
+ mockGetExtensions.mockReturnValue([]);
202
+ const command = extensionsCommand();
203
+ if (!command.action) throw new Error('Action not defined');
204
+ await command.action(mockContext, '');
205
+
206
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
207
+ type: MessageType.INFO,
208
+ text: 'No extensions installed. Run `/extensions explore` to check out the gallery.',
209
+ });
210
+ });
211
+ });
212
+
213
+ describe('completeExtensions', () => {
214
+ it.each([
215
+ {
216
+ description: 'should return matching extension names',
217
+ partialArg: 'ext',
218
+ expected: ['ext-one', 'ext-two'],
219
+ },
220
+ {
221
+ description: 'should return --all when partialArg matches',
222
+ partialArg: '--al',
223
+ expected: ['--all'],
224
+ },
225
+ {
226
+ description:
227
+ 'should return both extension names and --all when both match',
228
+ partialArg: 'all',
229
+ expected: ['--all', 'all-ext'],
230
+ },
231
+ {
232
+ description: 'should return an empty array if no matches',
233
+ partialArg: 'nomatch',
234
+ expected: [],
235
+ },
236
+ {
237
+ description:
238
+ 'should suggest only disabled extension names for the enable command',
239
+ partialArg: 'ext',
240
+ expected: ['ext-one'],
241
+ command: 'enable',
242
+ },
243
+ {
244
+ description:
245
+ 'should suggest only enabled extension names for the disable command',
246
+ partialArg: 'ext',
247
+ expected: ['ext-two'],
248
+ command: 'disable',
249
+ },
250
+ ])('$description', async ({ partialArg, expected, command }) => {
251
+ if (command) {
252
+ mockContext.invocation!.name = command;
253
+ }
254
+ const suggestions = completeExtensions(mockContext, partialArg);
255
+ expect(suggestions).toEqual(expected);
256
+ });
257
+ });
258
+
259
+ describe('completeExtensionsAndScopes', () => {
260
+ it('expands the list of suggestions with --scope args', () => {
261
+ const suggestions = completeExtensionsAndScopes(mockContext, 'ext');
262
+ expect(suggestions).toEqual([
263
+ 'ext-one --scope user',
264
+ 'ext-one --scope workspace',
265
+ 'ext-one --scope session',
266
+ 'ext-two --scope user',
267
+ 'ext-two --scope workspace',
268
+ 'ext-two --scope session',
269
+ ]);
270
+ });
271
+ });
272
+
273
+ describe('update', () => {
274
+ const updateAction = extensionsCommand().subCommands?.find(
275
+ (cmd) => cmd.name === 'update',
276
+ )?.action;
277
+
278
+ if (!updateAction) {
279
+ throw new Error('Update action not found');
280
+ }
281
+
282
+ it('should show usage if no args are provided', async () => {
283
+ await updateAction(mockContext, '');
284
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
285
+ type: MessageType.ERROR,
286
+ text: 'Usage: /extensions update <extension-names>|--all',
287
+ });
288
+ });
289
+
290
+ it('should show a message if no extensions are installed', async () => {
291
+ mockGetExtensions.mockReturnValue([]);
292
+ await updateAction(mockContext, 'ext-one');
293
+
294
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
295
+ type: MessageType.INFO,
296
+ text: 'No extensions installed. Run `/extensions explore` to check out the gallery.',
297
+ });
298
+ });
299
+
300
+ it('should inform user if there are no extensions to update with --all', async () => {
301
+ mockDispatchExtensionState.mockImplementationOnce(
302
+ async (action: ExtensionUpdateAction) => {
303
+ if (action.type === 'SCHEDULE_UPDATE') {
304
+ action.payload.onComplete([]);
305
+ }
306
+ },
307
+ );
308
+
309
+ await updateAction(mockContext, '--all');
310
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
311
+ type: MessageType.INFO,
312
+ text: 'No extensions to update.',
313
+ });
314
+ });
315
+
316
+ it('should call setPendingItem and addItem in a finally block on success', async () => {
317
+ mockDispatchExtensionState.mockImplementationOnce(
318
+ async (action: ExtensionUpdateAction) => {
319
+ if (action.type === 'SCHEDULE_UPDATE') {
320
+ action.payload.onComplete([
321
+ {
322
+ name: 'ext-one',
323
+ originalVersion: '1.0.0',
324
+ updatedVersion: '1.0.1',
325
+ },
326
+ {
327
+ name: 'ext-two',
328
+ originalVersion: '2.0.0',
329
+ updatedVersion: '2.0.1',
330
+ },
331
+ ]);
332
+ }
333
+ },
334
+ );
335
+ await updateAction(mockContext, '--all');
336
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
337
+ type: MessageType.EXTENSIONS_LIST,
338
+ extensions: expect.any(Array),
339
+ });
340
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
341
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
342
+ type: MessageType.EXTENSIONS_LIST,
343
+ extensions: expect.any(Array),
344
+ });
345
+ });
346
+
347
+ it('should call setPendingItem and addItem in a finally block on failure', async () => {
348
+ mockDispatchExtensionState.mockImplementationOnce((_) => {
349
+ throw new Error('Something went wrong');
350
+ });
351
+ await updateAction(mockContext, '--all');
352
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
353
+ type: MessageType.EXTENSIONS_LIST,
354
+ extensions: expect.any(Array),
355
+ });
356
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
357
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
358
+ type: MessageType.EXTENSIONS_LIST,
359
+ extensions: expect.any(Array),
360
+ });
361
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
362
+ type: MessageType.ERROR,
363
+ text: 'Something went wrong',
364
+ });
365
+ });
366
+
367
+ it('should update a single extension by name', async () => {
368
+ mockDispatchExtensionState.mockImplementationOnce(
369
+ async (action: ExtensionUpdateAction) => {
370
+ if (action.type === 'SCHEDULE_UPDATE') {
371
+ action.payload.onComplete([
372
+ {
373
+ name: 'ext-one',
374
+ originalVersion: '1.0.0',
375
+ updatedVersion: '1.0.1',
376
+ },
377
+ ]);
378
+ }
379
+ },
380
+ );
381
+ await updateAction(mockContext, 'ext-one');
382
+ expect(mockDispatchExtensionState).toHaveBeenCalledWith({
383
+ type: 'SCHEDULE_UPDATE',
384
+ payload: {
385
+ all: false,
386
+ names: ['ext-one'],
387
+ onComplete: expect.any(Function),
388
+ },
389
+ });
390
+ });
391
+
392
+ it('should update multiple extensions by name', async () => {
393
+ mockDispatchExtensionState.mockImplementationOnce(
394
+ async (action: ExtensionUpdateAction) => {
395
+ if (action.type === 'SCHEDULE_UPDATE') {
396
+ action.payload.onComplete([
397
+ {
398
+ name: 'ext-one',
399
+ originalVersion: '1.0.0',
400
+ updatedVersion: '1.0.1',
401
+ },
402
+ {
403
+ name: 'ext-two',
404
+ originalVersion: '1.0.0',
405
+ updatedVersion: '1.0.1',
406
+ },
407
+ ]);
408
+ }
409
+ },
410
+ );
411
+ await updateAction(mockContext, 'ext-one ext-two');
412
+ expect(mockDispatchExtensionState).toHaveBeenCalledWith({
413
+ type: 'SCHEDULE_UPDATE',
414
+ payload: {
415
+ all: false,
416
+ names: ['ext-one', 'ext-two'],
417
+ onComplete: expect.any(Function),
418
+ },
419
+ });
420
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
421
+ type: MessageType.EXTENSIONS_LIST,
422
+ extensions: expect.any(Array),
423
+ });
424
+ expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
425
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
426
+ type: MessageType.EXTENSIONS_LIST,
427
+ extensions: expect.any(Array),
428
+ });
429
+ });
430
+ });
431
+
432
+ describe('explore', () => {
433
+ const exploreAction = extensionsCommand().subCommands?.find(
434
+ (cmd) => cmd.name === 'explore',
435
+ )?.action;
436
+
437
+ if (!exploreAction) {
438
+ throw new Error('Explore action not found');
439
+ }
440
+
441
+ it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
442
+ mockContext.services.settings.merged.experimental.extensionRegistry =
443
+ true;
444
+
445
+ const result = await exploreAction(mockContext, '');
446
+
447
+ expect(result).toBeDefined();
448
+ if (result?.type !== 'custom_dialog') {
449
+ throw new Error('Expected custom_dialog');
450
+ }
451
+
452
+ const component =
453
+ result.component as ReactElement<ExtensionRegistryViewProps>;
454
+ expect(component.type).toBe(ExtensionRegistryView);
455
+ expect(component.props.extensionManager).toBe(mockExtensionLoader);
456
+ });
457
+
458
+ it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
459
+ mockContext.services.settings.merged.experimental.extensionRegistry =
460
+ true;
461
+
462
+ const result = await exploreAction(mockContext, '');
463
+ if (result?.type !== 'custom_dialog') {
464
+ throw new Error('Expected custom_dialog');
465
+ }
466
+
467
+ const component =
468
+ result.component as ReactElement<ExtensionRegistryViewProps>;
469
+
470
+ const extension = {
471
+ extensionName: 'test-ext',
472
+ url: 'https://github.com/test/ext.git',
473
+ } as RegistryExtension;
474
+
475
+ vi.mocked(inferInstallMetadata).mockResolvedValue({
476
+ source: extension.url,
477
+ type: 'git',
478
+ });
479
+ mockInstallExtension.mockResolvedValue({ name: extension.url });
480
+
481
+ // Call onSelect
482
+ await component.props.onSelect?.(extension);
483
+
484
+ await waitFor(() => {
485
+ expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
486
+ expect(mockInstallExtension).toHaveBeenCalledWith(
487
+ {
488
+ source: extension.url,
489
+ type: 'git',
490
+ },
491
+ undefined,
492
+ undefined,
493
+ );
494
+ });
495
+ expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
496
+
497
+ // Call onClose
498
+ component.props.onClose?.();
499
+ expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2);
500
+ });
501
+
502
+ it("should add an info message and call 'open' in a non-sandbox environment", async () => {
503
+ // Ensure no special environment variables that would affect behavior
504
+ vi.stubEnv('NODE_ENV', '');
505
+ vi.stubEnv('SANDBOX', '');
506
+
507
+ await exploreAction(mockContext, '');
508
+
509
+ const extensionsUrl = 'https://geminicli.com/extensions/';
510
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
511
+ type: MessageType.INFO,
512
+ text: `Opening extensions page in your browser: ${extensionsUrl}`,
513
+ });
514
+
515
+ expect(open).toHaveBeenCalledWith(extensionsUrl);
516
+ });
517
+
518
+ it('should only add an info message in a sandbox environment', async () => {
519
+ // Simulate a sandbox environment
520
+ vi.stubEnv('NODE_ENV', '');
521
+ vi.stubEnv('SANDBOX', 'gemini-sandbox');
522
+ const extensionsUrl = 'https://geminicli.com/extensions/';
523
+
524
+ await exploreAction(mockContext, '');
525
+
526
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
527
+ type: MessageType.INFO,
528
+ text: `View available extensions at ${extensionsUrl}`,
529
+ });
530
+
531
+ // Ensure 'open' was not called in the sandbox
532
+ expect(open).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it('should add an info message and not call open in NODE_ENV test environment', async () => {
536
+ vi.stubEnv('NODE_ENV', 'test');
537
+ vi.stubEnv('SANDBOX', '');
538
+ const extensionsUrl = 'https://geminicli.com/extensions/';
539
+
540
+ await exploreAction(mockContext, '');
541
+
542
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
543
+ type: MessageType.INFO,
544
+ text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`,
545
+ });
546
+
547
+ // Ensure 'open' was not called in test environment
548
+ expect(open).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('should handle errors when opening the browser', async () => {
552
+ vi.stubEnv('NODE_ENV', '');
553
+ const extensionsUrl = 'https://geminicli.com/extensions/';
554
+ const errorMessage = 'Failed to open browser';
555
+ vi.mocked(open).mockRejectedValue(new Error(errorMessage));
556
+
557
+ await exploreAction(mockContext, '');
558
+
559
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
560
+ type: MessageType.ERROR,
561
+ text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`,
562
+ });
563
+ });
564
+ });
565
+
566
+ describe('when enableExtensionReloading is true', () => {
567
+ it('should include enable, disable, install, link, and uninstall subcommands', () => {
568
+ const command = extensionsCommand(true);
569
+ const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
570
+ expect(subCommandNames).toContain('enable');
571
+ expect(subCommandNames).toContain('disable');
572
+ expect(subCommandNames).toContain('install');
573
+ expect(subCommandNames).toContain('link');
574
+ expect(subCommandNames).toContain('uninstall');
575
+ });
576
+ });
577
+
578
+ describe('when enableExtensionReloading is false', () => {
579
+ it('should not include enable, disable, install, link, and uninstall subcommands', () => {
580
+ const command = extensionsCommand(false);
581
+ const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
582
+ expect(subCommandNames).not.toContain('enable');
583
+ expect(subCommandNames).not.toContain('disable');
584
+ expect(subCommandNames).not.toContain('install');
585
+ expect(subCommandNames).not.toContain('link');
586
+ expect(subCommandNames).not.toContain('uninstall');
587
+ });
588
+ });
589
+
590
+ describe('when enableExtensionReloading is not provided', () => {
591
+ it('should not include enable, disable, install, link, and uninstall subcommands by default', () => {
592
+ const command = extensionsCommand();
593
+ const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
594
+ expect(subCommandNames).not.toContain('enable');
595
+ expect(subCommandNames).not.toContain('disable');
596
+ expect(subCommandNames).not.toContain('install');
597
+ expect(subCommandNames).not.toContain('link');
598
+ expect(subCommandNames).not.toContain('uninstall');
599
+ });
600
+ });
601
+
602
+ describe('install', () => {
603
+ let installAction: SlashCommand['action'];
604
+
605
+ beforeEach(() => {
606
+ installAction = extensionsCommand(true).subCommands?.find(
607
+ (cmd) => cmd.name === 'install',
608
+ )?.action;
609
+
610
+ expect(installAction).not.toBeNull();
611
+
612
+ mockContext.invocation!.name = 'install';
613
+ });
614
+
615
+ it('should show usage if no extension name is provided', async () => {
616
+ await installAction!(mockContext, '');
617
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
618
+ type: MessageType.ERROR,
619
+ text: 'Usage: /extensions install <source>',
620
+ });
621
+ expect(mockInstallExtension).not.toHaveBeenCalled();
622
+ });
623
+
624
+ it('should call installExtension and show success message', async () => {
625
+ const packageName = 'test-extension-package';
626
+ vi.mocked(inferInstallMetadata).mockResolvedValue({
627
+ source: packageName,
628
+ type: 'git',
629
+ });
630
+ mockInstallExtension.mockResolvedValue({ name: packageName });
631
+ await installAction!(mockContext, packageName);
632
+ expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
633
+ expect(mockInstallExtension).toHaveBeenCalledWith(
634
+ {
635
+ source: packageName,
636
+ type: 'git',
637
+ },
638
+ undefined,
639
+ undefined,
640
+ );
641
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
642
+ type: MessageType.INFO,
643
+ text: `Installing extension from "${packageName}"...`,
644
+ });
645
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
646
+ type: MessageType.INFO,
647
+ text: `Extension "${packageName}" installed successfully.`,
648
+ });
649
+ });
650
+
651
+ it('should show error message on installation failure', async () => {
652
+ const packageName = 'failed-extension';
653
+ const errorMessage = 'install failed';
654
+ vi.mocked(inferInstallMetadata).mockResolvedValue({
655
+ source: packageName,
656
+ type: 'git',
657
+ });
658
+ mockInstallExtension.mockRejectedValue(new Error(errorMessage));
659
+
660
+ await installAction!(mockContext, packageName);
661
+ expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
662
+ expect(mockInstallExtension).toHaveBeenCalledWith(
663
+ {
664
+ source: packageName,
665
+ type: 'git',
666
+ },
667
+ undefined,
668
+ undefined,
669
+ );
670
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
671
+ type: MessageType.ERROR,
672
+ text: `Failed to install extension from "${packageName}": ${errorMessage}`,
673
+ });
674
+ });
675
+
676
+ it('should show error message for invalid source', async () => {
677
+ const invalidSource = 'a;b';
678
+ await installAction!(mockContext, invalidSource);
679
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
680
+ type: MessageType.ERROR,
681
+ text: `Invalid source: ${invalidSource}`,
682
+ });
683
+ expect(mockInstallExtension).not.toHaveBeenCalled();
684
+ });
685
+ });
686
+
687
+ describe('link', () => {
688
+ let linkAction: SlashCommand['action'];
689
+
690
+ beforeEach(() => {
691
+ linkAction = extensionsCommand(true).subCommands?.find(
692
+ (cmd) => cmd.name === 'link',
693
+ )?.action;
694
+
695
+ expect(linkAction).not.toBeNull();
696
+ mockContext.invocation!.name = 'link';
697
+ });
698
+
699
+ it('should show usage if no extension is provided', async () => {
700
+ await linkAction!(mockContext, '');
701
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
702
+ type: MessageType.ERROR,
703
+ text: 'Usage: /extensions link <source>',
704
+ });
705
+ expect(mockInstallExtension).not.toHaveBeenCalled();
706
+ });
707
+
708
+ it('should call installExtension and show success message', async () => {
709
+ const packageName = 'test-extension-package';
710
+ mockInstallExtension.mockResolvedValue({ name: packageName });
711
+ vi.mocked(stat).mockResolvedValue({
712
+ size: 100,
713
+ } as Stats);
714
+ await linkAction!(mockContext, packageName);
715
+ expect(mockInstallExtension).toHaveBeenCalledWith(
716
+ {
717
+ source: packageName,
718
+ type: 'link',
719
+ },
720
+ undefined,
721
+ undefined,
722
+ );
723
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
724
+ type: MessageType.INFO,
725
+ text: `Linking extension from "${packageName}"...`,
726
+ });
727
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
728
+ type: MessageType.INFO,
729
+ text: `Extension "${packageName}" linked successfully.`,
730
+ });
731
+ });
732
+
733
+ it('should show error message on linking failure', async () => {
734
+ const packageName = 'test-extension-package';
735
+ const errorMessage = 'link failed';
736
+ mockInstallExtension.mockRejectedValue(new Error(errorMessage));
737
+ vi.mocked(stat).mockResolvedValue({
738
+ size: 100,
739
+ } as Stats);
740
+
741
+ await linkAction!(mockContext, packageName);
742
+ expect(mockInstallExtension).toHaveBeenCalledWith(
743
+ {
744
+ source: packageName,
745
+ type: 'link',
746
+ },
747
+ undefined,
748
+ undefined,
749
+ );
750
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
751
+ type: MessageType.ERROR,
752
+ text: `Failed to link extension from "${packageName}": ${errorMessage}`,
753
+ });
754
+ });
755
+
756
+ it('should show error message for invalid source', async () => {
757
+ const packageName = 'test-extension-package';
758
+ const errorMessage = 'invalid path';
759
+ vi.mocked(stat).mockRejectedValue(new Error(errorMessage));
760
+ await linkAction!(mockContext, packageName);
761
+ expect(mockInstallExtension).not.toHaveBeenCalled();
762
+ });
763
+ });
764
+
765
+ describe('uninstall', () => {
766
+ let uninstallAction: SlashCommand['action'];
767
+
768
+ beforeEach(() => {
769
+ uninstallAction = extensionsCommand(true).subCommands?.find(
770
+ (cmd) => cmd.name === 'uninstall',
771
+ )?.action;
772
+
773
+ expect(uninstallAction).not.toBeNull();
774
+
775
+ mockContext.invocation!.name = 'uninstall';
776
+ });
777
+
778
+ it('should show usage if no extension name is provided', async () => {
779
+ await uninstallAction!(mockContext, '');
780
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
781
+ type: MessageType.ERROR,
782
+ text: 'Usage: /extensions uninstall <extension-names...>|--all',
783
+ });
784
+ expect(mockUninstallExtension).not.toHaveBeenCalled();
785
+ });
786
+
787
+ it('should expose "delete" as an alias', () => {
788
+ const uninstallCmd = extensionsCommand(true).subCommands?.find(
789
+ (cmd) => cmd.name === 'uninstall',
790
+ );
791
+ expect(uninstallCmd?.altNames).toContain('delete');
792
+ });
793
+
794
+ it('should call uninstallExtension and show success message', async () => {
795
+ const extensionName = 'test-extension';
796
+ await uninstallAction!(mockContext, extensionName);
797
+ expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
798
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
799
+ type: MessageType.INFO,
800
+ text: `Uninstalling extension "${extensionName}"...`,
801
+ });
802
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
803
+ type: MessageType.INFO,
804
+ text: `Extension "${extensionName}" uninstalled successfully.`,
805
+ });
806
+ });
807
+
808
+ it('should show error message on uninstallation failure', async () => {
809
+ const extensionName = 'failed-extension';
810
+ const errorMessage = 'uninstall failed';
811
+ mockUninstallExtension.mockRejectedValue(new Error(errorMessage));
812
+
813
+ await uninstallAction!(mockContext, extensionName);
814
+ expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
815
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
816
+ type: MessageType.ERROR,
817
+ text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`,
818
+ });
819
+ });
820
+ });
821
+
822
+ describe('enable', () => {
823
+ let enableAction: SlashCommand['action'];
824
+
825
+ beforeEach(() => {
826
+ enableAction = extensionsCommand(true).subCommands?.find(
827
+ (cmd) => cmd.name === 'enable',
828
+ )?.action;
829
+
830
+ expect(enableAction).not.toBeNull();
831
+
832
+ mockContext.invocation!.name = 'enable';
833
+ });
834
+
835
+ it('should show usage if no extension name is provided', async () => {
836
+ await enableAction!(mockContext, '');
837
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
838
+ type: MessageType.ERROR,
839
+ text: 'Usage: /extensions enable <extension> [--scope=<user|workspace|session>]',
840
+ });
841
+ });
842
+
843
+ it('should call enableExtension with the provided scope', async () => {
844
+ await enableAction!(mockContext, `${inactiveExt.name} --scope=user`);
845
+ expect(mockEnableExtension).toHaveBeenCalledWith(
846
+ inactiveExt.name,
847
+ SettingScope.User,
848
+ );
849
+
850
+ await enableAction!(mockContext, `${inactiveExt.name} --scope workspace`);
851
+ expect(mockEnableExtension).toHaveBeenCalledWith(
852
+ inactiveExt.name,
853
+ SettingScope.Workspace,
854
+ );
855
+ });
856
+
857
+ it('should support --all', async () => {
858
+ mockGetExtensions.mockReturnValue([
859
+ inactiveExt,
860
+ { ...inactiveExt, name: 'another-inactive-ext' },
861
+ ]);
862
+ await enableAction!(mockContext, '--all --scope session');
863
+ expect(mockEnableExtension).toHaveBeenCalledWith(
864
+ inactiveExt.name,
865
+ SettingScope.Session,
866
+ );
867
+ expect(mockEnableExtension).toHaveBeenCalledWith(
868
+ 'another-inactive-ext',
869
+ SettingScope.Session,
870
+ );
871
+ });
872
+ });
873
+
874
+ describe('disable', () => {
875
+ let disableAction: SlashCommand['action'];
876
+
877
+ beforeEach(() => {
878
+ disableAction = extensionsCommand(true).subCommands?.find(
879
+ (cmd) => cmd.name === 'disable',
880
+ )?.action;
881
+
882
+ expect(disableAction).not.toBeNull();
883
+
884
+ mockContext.invocation!.name = 'disable';
885
+ });
886
+
887
+ it('should show usage if no extension name is provided', async () => {
888
+ await disableAction!(mockContext, '');
889
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
890
+ type: MessageType.ERROR,
891
+ text: 'Usage: /extensions disable <extension> [--scope=<user|workspace|session>]',
892
+ });
893
+ });
894
+
895
+ it('should call disableExtension with the provided scope', async () => {
896
+ await disableAction!(mockContext, `${activeExt.name} --scope=user`);
897
+ expect(mockDisableExtension).toHaveBeenCalledWith(
898
+ activeExt.name,
899
+ SettingScope.User,
900
+ );
901
+
902
+ await disableAction!(mockContext, `${activeExt.name} --scope workspace`);
903
+ expect(mockDisableExtension).toHaveBeenCalledWith(
904
+ activeExt.name,
905
+ SettingScope.Workspace,
906
+ );
907
+ });
908
+
909
+ it('should support --all', async () => {
910
+ mockGetExtensions.mockReturnValue([
911
+ activeExt,
912
+ { ...activeExt, name: 'another-active-ext' },
913
+ ]);
914
+ await disableAction!(mockContext, '--all --scope session');
915
+ expect(mockDisableExtension).toHaveBeenCalledWith(
916
+ activeExt.name,
917
+ SettingScope.Session,
918
+ );
919
+ expect(mockDisableExtension).toHaveBeenCalledWith(
920
+ 'another-active-ext',
921
+ SettingScope.Session,
922
+ );
923
+ });
924
+ });
925
+
926
+ describe('reload', () => {
927
+ let restartAction: SlashCommand['action'];
928
+ let mockRestartExtension: MockedFunction<
929
+ typeof ExtensionLoader.prototype.restartExtension
930
+ >;
931
+
932
+ beforeEach(() => {
933
+ restartAction = extensionsCommand().subCommands?.find(
934
+ (c) => c.name === 'reload',
935
+ )?.action;
936
+ expect(restartAction).not.toBeNull();
937
+
938
+ mockRestartExtension = vi.fn();
939
+ mockContext.services.agentContext!.config.getExtensionLoader = vi
940
+ .fn()
941
+ .mockImplementation(() => ({
942
+ getExtensions: mockGetExtensions,
943
+ restartExtension: mockRestartExtension,
944
+ }));
945
+ mockContext.invocation!.name = 'reload';
946
+ });
947
+
948
+ it('should show a message if no extensions are installed', async () => {
949
+ mockContext.services.agentContext!.config.getExtensionLoader = vi
950
+ .fn()
951
+ .mockImplementation(() => ({
952
+ getExtensions: () => [],
953
+ restartExtension: mockRestartExtension,
954
+ }));
955
+
956
+ await restartAction!(mockContext, '--all');
957
+
958
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
959
+ type: MessageType.INFO,
960
+ text: 'No extensions installed. Run `/extensions explore` to check out the gallery.',
961
+ });
962
+ });
963
+
964
+ it('reloads all active extensions when --all is provided', async () => {
965
+ const mockExtensions = [
966
+ { name: 'ext1', isActive: true },
967
+ { name: 'ext2', isActive: true },
968
+ { name: 'ext3', isActive: false },
969
+ ] as GeminiCLIExtension[];
970
+ mockGetExtensions.mockReturnValue(mockExtensions);
971
+
972
+ await restartAction!(mockContext, '--all');
973
+
974
+ expect(mockRestartExtension).toHaveBeenCalledTimes(2);
975
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
976
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]);
977
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
978
+ expect.objectContaining({
979
+ type: MessageType.INFO,
980
+ text: 'Reloading 2 extensions...',
981
+ }),
982
+ );
983
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
984
+ expect.objectContaining({
985
+ type: MessageType.INFO,
986
+ text: '2 extensions reloaded successfully',
987
+ }),
988
+ );
989
+ expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
990
+ type: 'RESTARTED',
991
+ payload: { name: 'ext1' },
992
+ });
993
+ expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
994
+ type: 'RESTARTED',
995
+ payload: { name: 'ext2' },
996
+ });
997
+ expect(mockReloadSkills).toHaveBeenCalled();
998
+ expect(mockReloadAgents).toHaveBeenCalled();
999
+ });
1000
+
1001
+ it('handles errors during skill or agent reload', async () => {
1002
+ const mockExtensions = [
1003
+ { name: 'ext1', isActive: true },
1004
+ ] as GeminiCLIExtension[];
1005
+ mockGetExtensions.mockReturnValue(mockExtensions);
1006
+ mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills'));
1007
+
1008
+ await restartAction!(mockContext, '--all');
1009
+
1010
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
1011
+ expect(mockReloadSkills).toHaveBeenCalled();
1012
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1013
+ expect.objectContaining({
1014
+ type: MessageType.ERROR,
1015
+ text: 'Failed to reload skills or agents: Failed to reload skills',
1016
+ }),
1017
+ );
1018
+ });
1019
+
1020
+ it('reloads only specified active extensions', async () => {
1021
+ const mockExtensions = [
1022
+ { name: 'ext1', isActive: false },
1023
+ { name: 'ext2', isActive: true },
1024
+ { name: 'ext3', isActive: true },
1025
+ ] as GeminiCLIExtension[];
1026
+ mockGetExtensions.mockReturnValue(mockExtensions);
1027
+
1028
+ await restartAction!(mockContext, 'ext1 ext3');
1029
+
1030
+ expect(mockRestartExtension).toHaveBeenCalledTimes(1);
1031
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]);
1032
+ expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
1033
+ type: 'RESTARTED',
1034
+ payload: { name: 'ext3' },
1035
+ });
1036
+ });
1037
+
1038
+ it('shows an error if no extension loader is available', async () => {
1039
+ mockContext.services.agentContext!.config.getExtensionLoader = vi.fn();
1040
+
1041
+ await restartAction!(mockContext, '--all');
1042
+
1043
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1044
+ expect.objectContaining({
1045
+ type: MessageType.ERROR,
1046
+ text: "Extensions are not yet loaded, can't restart yet",
1047
+ }),
1048
+ );
1049
+ expect(mockRestartExtension).not.toHaveBeenCalled();
1050
+ });
1051
+
1052
+ it('shows usage error for no arguments', async () => {
1053
+ await restartAction!(mockContext, '');
1054
+
1055
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1056
+ expect.objectContaining({
1057
+ type: MessageType.ERROR,
1058
+ text: 'Usage: /extensions reload <extension-names>|--all',
1059
+ }),
1060
+ );
1061
+ expect(mockRestartExtension).not.toHaveBeenCalled();
1062
+ });
1063
+
1064
+ it('handles errors during extension reload', async () => {
1065
+ const mockExtensions = [
1066
+ { name: 'ext1', isActive: true },
1067
+ ] as GeminiCLIExtension[];
1068
+ mockGetExtensions.mockReturnValue(mockExtensions);
1069
+ mockRestartExtension.mockRejectedValue(new Error('Failed to restart'));
1070
+
1071
+ await restartAction!(mockContext, '--all');
1072
+
1073
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
1074
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1075
+ expect.objectContaining({
1076
+ type: MessageType.ERROR,
1077
+ text: 'Failed to reload some extensions:\n ext1: Failed to restart',
1078
+ }),
1079
+ );
1080
+ });
1081
+
1082
+ it('shows a warning if an extension is not found', async () => {
1083
+ const mockExtensions = [
1084
+ { name: 'ext1', isActive: true },
1085
+ ] as GeminiCLIExtension[];
1086
+ mockGetExtensions.mockReturnValue(mockExtensions);
1087
+
1088
+ await restartAction!(mockContext, 'ext1 ext2');
1089
+
1090
+ expect(mockRestartExtension).toHaveBeenCalledTimes(1);
1091
+ expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
1092
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1093
+ expect.objectContaining({
1094
+ type: MessageType.WARNING,
1095
+ text: 'Extension(s) not found or not active: ext2',
1096
+ }),
1097
+ );
1098
+ });
1099
+
1100
+ it('does not reload any extensions if none are found', async () => {
1101
+ const mockExtensions = [
1102
+ { name: 'ext1', isActive: true },
1103
+ ] as GeminiCLIExtension[];
1104
+ mockGetExtensions.mockReturnValue(mockExtensions);
1105
+
1106
+ await restartAction!(mockContext, 'ext2 ext3');
1107
+
1108
+ expect(mockRestartExtension).not.toHaveBeenCalled();
1109
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
1110
+ expect.objectContaining({
1111
+ type: MessageType.WARNING,
1112
+ text: 'Extension(s) not found or not active: ext2, ext3',
1113
+ }),
1114
+ );
1115
+ });
1116
+
1117
+ it('should suggest only enabled extension names for the reload command', async () => {
1118
+ mockContext.invocation!.name = 'reload';
1119
+ const mockExtensions = [
1120
+ { name: 'ext1', isActive: true },
1121
+ { name: 'ext2', isActive: false },
1122
+ ] as GeminiCLIExtension[];
1123
+ mockGetExtensions.mockReturnValue(mockExtensions);
1124
+
1125
+ const suggestions = completeExtensions(mockContext, 'ext');
1126
+ expect(suggestions).toEqual(['ext1']);
1127
+ });
1128
+ });
1129
+
1130
+ describe('config', () => {
1131
+ let configAction: SlashCommand['action'];
1132
+
1133
+ beforeEach(async () => {
1134
+ configAction = extensionsCommand(true).subCommands?.find(
1135
+ (cmd) => cmd.name === 'config',
1136
+ )?.action;
1137
+
1138
+ expect(configAction).not.toBeNull();
1139
+ mockContext.invocation!.name = 'config';
1140
+
1141
+ const prompts = (await import('prompts')).default;
1142
+ vi.mocked(prompts).mockResolvedValue({ overwrite: true });
1143
+
1144
+ const { getScopedEnvContents } = await import(
1145
+ '../../config/extensions/extensionSettings.js'
1146
+ );
1147
+ vi.mocked(getScopedEnvContents).mockResolvedValue({});
1148
+ });
1149
+
1150
+ it('should return dialog to configure all extensions if no args provided', async () => {
1151
+ const result = await configAction!(mockContext, '');
1152
+ if (result?.type !== 'custom_dialog') {
1153
+ throw new Error('Expected custom_dialog');
1154
+ }
1155
+ const dialogResult = result;
1156
+ const component =
1157
+ dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
1158
+ expect(component.type).toBe(ConfigExtensionDialog);
1159
+ expect(component.props.configureAll).toBe(true);
1160
+ expect(component.props.extensionManager).toBeDefined();
1161
+ });
1162
+
1163
+ it('should return dialog to configure specific extension', async () => {
1164
+ const result = await configAction!(mockContext, 'ext-one');
1165
+ if (result?.type !== 'custom_dialog') {
1166
+ throw new Error('Expected custom_dialog');
1167
+ }
1168
+ const dialogResult = result;
1169
+ const component =
1170
+ dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
1171
+ expect(component.type).toBe(ConfigExtensionDialog);
1172
+ expect(component.props.extensionName).toBe('ext-one');
1173
+ expect(component.props.settingKey).toBeUndefined();
1174
+ expect(component.props.configureAll).toBe(false);
1175
+ });
1176
+
1177
+ it('should return dialog to configure specific setting for an extension', async () => {
1178
+ const result = await configAction!(mockContext, 'ext-one SETTING1');
1179
+ if (result?.type !== 'custom_dialog') {
1180
+ throw new Error('Expected custom_dialog');
1181
+ }
1182
+ const dialogResult = result;
1183
+ const component =
1184
+ dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
1185
+ expect(component.type).toBe(ConfigExtensionDialog);
1186
+ expect(component.props.extensionName).toBe('ext-one');
1187
+ expect(component.props.settingKey).toBe('SETTING1');
1188
+ expect(component.props.scope).toBe('user'); // Default scope
1189
+ });
1190
+
1191
+ it('should respect scope argument passed to dialog', async () => {
1192
+ const result = await configAction!(
1193
+ mockContext,
1194
+ 'ext-one SETTING1 --scope=workspace',
1195
+ );
1196
+ if (result?.type !== 'custom_dialog') {
1197
+ throw new Error('Expected custom_dialog');
1198
+ }
1199
+ const dialogResult = result;
1200
+ const component =
1201
+ dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
1202
+ expect(component.props.scope).toBe('workspace');
1203
+ });
1204
+
1205
+ it('should show error for invalid extension name', async () => {
1206
+ await configAction!(mockContext, '../invalid');
1207
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
1208
+ type: MessageType.ERROR,
1209
+ text: 'Invalid extension name. Names cannot contain path separators or "..".',
1210
+ });
1211
+ });
1212
+
1213
+ // "should inform if extension has no settings" - This check is now inside ConfigExtensionDialog logic.
1214
+ // We can test that we still return a dialog, and the dialog will handle logical checks via utils.ts
1215
+ // For unit testing extensionsCommand, we just ensure delegation.
1216
+ it('should return dialog even if extension has no settings (dialog handles logic)', async () => {
1217
+ const result = await configAction!(mockContext, 'ext-one');
1218
+ if (result?.type !== 'custom_dialog') {
1219
+ throw new Error('Expected custom_dialog');
1220
+ }
1221
+ const dialogResult = result;
1222
+ const component =
1223
+ dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
1224
+ expect(component.type).toBe(ConfigExtensionDialog);
1225
+ });
1226
+ });
1227
+ });
packages/cli/src/ui/commands/extensionsCommand.ts ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ debugLogger,
9
+ listExtensions,
10
+ getErrorMessage,
11
+ type ExtensionInstallMetadata,
12
+ } from '@google/gemini-cli-core';
13
+ import type { ExtensionUpdateInfo } from '../../config/extension.js';
14
+ import {
15
+ emptyIcon,
16
+ MessageType,
17
+ type HistoryItemExtensionsList,
18
+ type HistoryItemInfo,
19
+ } from '../types.js';
20
+ import {
21
+ type CommandContext,
22
+ type SlashCommand,
23
+ type SlashCommandActionReturn,
24
+ CommandKind,
25
+ } from './types.js';
26
+ import open from 'open';
27
+ import process from 'node:process';
28
+ import {
29
+ ExtensionManager,
30
+ inferInstallMetadata,
31
+ } from '../../config/extension-manager.js';
32
+ import { SettingScope } from '../../config/settings.js';
33
+ import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
34
+ import { theme } from '../semantic-colors.js';
35
+ import { stat } from 'node:fs/promises';
36
+ import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
37
+ import { type ConfigLogger } from '../../commands/extensions/utils.js';
38
+ import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
39
+ import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
40
+ import React from 'react';
41
+
42
+ function showMessageIfNoExtensions(
43
+ context: CommandContext,
44
+ extensions: unknown[],
45
+ ): boolean {
46
+ if (extensions.length === 0) {
47
+ context.ui.addItem({
48
+ type: MessageType.INFO,
49
+ text: 'No extensions installed. Run `/extensions explore` to check out the gallery.',
50
+ });
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ async function listAction(context: CommandContext) {
57
+ const extensions = context.services.agentContext?.config
58
+ ? listExtensions(context.services.agentContext.config)
59
+ : [];
60
+
61
+ if (showMessageIfNoExtensions(context, extensions)) {
62
+ return;
63
+ }
64
+
65
+ const historyItem: HistoryItemExtensionsList = {
66
+ type: MessageType.EXTENSIONS_LIST,
67
+ extensions,
68
+ };
69
+
70
+ context.ui.addItem(historyItem);
71
+ }
72
+
73
+ function updateAction(context: CommandContext, args: string): Promise<void> {
74
+ const updateArgs = args.split(' ').filter((value) => value.length > 0);
75
+ const all = updateArgs.length === 1 && updateArgs[0] === '--all';
76
+ const names = all ? null : updateArgs;
77
+
78
+ if (!all && names?.length === 0) {
79
+ context.ui.addItem({
80
+ type: MessageType.ERROR,
81
+ text: 'Usage: /extensions update <extension-names>|--all',
82
+ });
83
+ return Promise.resolve();
84
+ }
85
+
86
+ let resolveUpdateComplete: (updateInfo: ExtensionUpdateInfo[]) => void;
87
+ const updateComplete = new Promise<ExtensionUpdateInfo[]>(
88
+ (resolve) => (resolveUpdateComplete = resolve),
89
+ );
90
+
91
+ const extensions = context.services.agentContext?.config
92
+ ? listExtensions(context.services.agentContext.config)
93
+ : [];
94
+
95
+ if (showMessageIfNoExtensions(context, extensions)) {
96
+ return Promise.resolve();
97
+ }
98
+
99
+ const historyItem: HistoryItemExtensionsList = {
100
+ type: MessageType.EXTENSIONS_LIST,
101
+ extensions,
102
+ };
103
+
104
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
105
+ updateComplete.then((updateInfos) => {
106
+ if (updateInfos.length === 0) {
107
+ context.ui.addItem({
108
+ type: MessageType.INFO,
109
+ text: 'No extensions to update.',
110
+ });
111
+ }
112
+
113
+ context.ui.addItem(historyItem);
114
+ context.ui.setPendingItem(null);
115
+ });
116
+
117
+ try {
118
+ context.ui.setPendingItem(historyItem);
119
+
120
+ context.ui.dispatchExtensionStateUpdate({
121
+ type: 'SCHEDULE_UPDATE',
122
+ payload: {
123
+ all,
124
+ names,
125
+ onComplete: (updateInfos) => {
126
+ resolveUpdateComplete(updateInfos);
127
+ },
128
+ },
129
+ });
130
+ if (names?.length) {
131
+ const extensions = listExtensions(context.services.agentContext!.config);
132
+ for (const name of names) {
133
+ const extension = extensions.find(
134
+ (extension) => extension.name === name,
135
+ );
136
+ if (!extension) {
137
+ context.ui.addItem({
138
+ type: MessageType.ERROR,
139
+ text: `Extension ${name} not found.`,
140
+ });
141
+ continue;
142
+ }
143
+ }
144
+ }
145
+ } catch (error) {
146
+ resolveUpdateComplete!([]);
147
+ context.ui.addItem({
148
+ type: MessageType.ERROR,
149
+ text: getErrorMessage(error),
150
+ });
151
+ }
152
+ return updateComplete.then((_) => {});
153
+ }
154
+
155
+ async function restartAction(
156
+ context: CommandContext,
157
+ args: string,
158
+ ): Promise<void> {
159
+ const extensionLoader =
160
+ context.services.agentContext?.config.getExtensionLoader();
161
+ if (!extensionLoader) {
162
+ context.ui.addItem({
163
+ type: MessageType.ERROR,
164
+ text: "Extensions are not yet loaded, can't restart yet",
165
+ });
166
+ return;
167
+ }
168
+
169
+ const extensions = extensionLoader.getExtensions();
170
+ if (showMessageIfNoExtensions(context, extensions)) {
171
+ return;
172
+ }
173
+
174
+ const restartArgs = args.split(' ').filter((value) => value.length > 0);
175
+ const all = restartArgs.length === 1 && restartArgs[0] === '--all';
176
+ const names = all ? null : restartArgs;
177
+ if (!all && names?.length === 0) {
178
+ context.ui.addItem({
179
+ type: MessageType.ERROR,
180
+ text: 'Usage: /extensions reload <extension-names>|--all',
181
+ });
182
+ return Promise.resolve();
183
+ }
184
+
185
+ let extensionsToRestart = extensionLoader
186
+ .getExtensions()
187
+ .filter((extension) => extension.isActive);
188
+ if (names) {
189
+ extensionsToRestart = extensionsToRestart.filter((extension) =>
190
+ names.includes(extension.name),
191
+ );
192
+ if (names.length !== extensionsToRestart.length) {
193
+ const notFound = names.filter(
194
+ (name) =>
195
+ !extensionsToRestart.some((extension) => extension.name === name),
196
+ );
197
+ if (notFound.length > 0) {
198
+ context.ui.addItem({
199
+ type: MessageType.WARNING,
200
+ text: `Extension(s) not found or not active: ${notFound.join(', ')}`,
201
+ });
202
+ }
203
+ }
204
+ }
205
+ if (extensionsToRestart.length === 0) {
206
+ // We will have logged a different message above already.
207
+ return;
208
+ }
209
+
210
+ const s = extensionsToRestart.length > 1 ? 's' : '';
211
+
212
+ const reloadingMessage = {
213
+ type: MessageType.INFO,
214
+ text: `Reloading ${extensionsToRestart.length} extension${s}...`,
215
+ color: theme.text.primary,
216
+ };
217
+ context.ui.addItem(reloadingMessage);
218
+
219
+ const results = await Promise.allSettled(
220
+ extensionsToRestart.map(async (extension) => {
221
+ if (extension.isActive) {
222
+ await extensionLoader.restartExtension(extension);
223
+ context.ui.dispatchExtensionStateUpdate({
224
+ type: 'RESTARTED',
225
+ payload: {
226
+ name: extension.name,
227
+ },
228
+ });
229
+ }
230
+ }),
231
+ );
232
+
233
+ const failures = results.filter(
234
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
235
+ );
236
+
237
+ if (failures.length < extensionsToRestart.length) {
238
+ try {
239
+ await context.services.agentContext?.config.reloadSkills();
240
+ await context.services.agentContext?.config.getAgentRegistry()?.reload();
241
+ } catch (error) {
242
+ context.ui.addItem({
243
+ type: MessageType.ERROR,
244
+ text: `Failed to reload skills or agents: ${getErrorMessage(error)}`,
245
+ });
246
+ }
247
+ }
248
+
249
+ if (failures.length > 0) {
250
+ const errorMessages = failures
251
+ .map((failure, index) => {
252
+ const extensionName = extensionsToRestart[index].name;
253
+ return `${extensionName}: ${getErrorMessage(failure.reason)}`;
254
+ })
255
+ .join('\n ');
256
+ context.ui.addItem({
257
+ type: MessageType.ERROR,
258
+ text: `Failed to reload some extensions:\n ${errorMessages}`,
259
+ });
260
+ } else {
261
+ const infoItem: HistoryItemInfo = {
262
+ type: MessageType.INFO,
263
+ text: `${extensionsToRestart.length} extension${s} reloaded successfully`,
264
+ icon: emptyIcon,
265
+ color: theme.text.primary,
266
+ };
267
+ context.ui.addItem(infoItem);
268
+ }
269
+ }
270
+
271
+ async function exploreAction(
272
+ context: CommandContext,
273
+ ): Promise<SlashCommandActionReturn | void> {
274
+ const settings = context.services.settings.merged;
275
+ const useRegistryUI = settings.experimental?.extensionRegistry;
276
+
277
+ if (useRegistryUI) {
278
+ const extensionManager =
279
+ context.services.agentContext?.config.getExtensionLoader();
280
+ if (extensionManager instanceof ExtensionManager) {
281
+ return {
282
+ type: 'custom_dialog' as const,
283
+ component: React.createElement(ExtensionRegistryView, {
284
+ onSelect: async (extension, requestConsentOverride) => {
285
+ debugLogger.log(`Selected extension: ${extension.extensionName}`);
286
+ await installAction(context, extension.url, requestConsentOverride);
287
+ context.ui.removeComponent();
288
+ },
289
+ onLink: async (extension, requestConsentOverride) => {
290
+ debugLogger.log(`Linking extension: ${extension.extensionName}`);
291
+ await linkAction(context, extension.url, requestConsentOverride);
292
+ context.ui.removeComponent();
293
+ },
294
+ onClose: () => context.ui.removeComponent(),
295
+ extensionManager,
296
+ }),
297
+ };
298
+ }
299
+ }
300
+
301
+ const extensionsUrl = 'https://geminicli.com/extensions/';
302
+
303
+ // Only check for NODE_ENV for explicit test mode, not for unit test framework
304
+ if (process.env['NODE_ENV'] === 'test') {
305
+ context.ui.addItem({
306
+ type: MessageType.INFO,
307
+ text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`,
308
+ });
309
+ } else if (
310
+ process.env['SANDBOX'] &&
311
+ process.env['SANDBOX'] !== 'sandbox-exec'
312
+ ) {
313
+ context.ui.addItem({
314
+ type: MessageType.INFO,
315
+ text: `View available extensions at ${extensionsUrl}`,
316
+ });
317
+ } else {
318
+ context.ui.addItem({
319
+ type: MessageType.INFO,
320
+ text: `Opening extensions page in your browser: ${extensionsUrl}`,
321
+ });
322
+ try {
323
+ await open(extensionsUrl);
324
+ } catch {
325
+ context.ui.addItem({
326
+ type: MessageType.ERROR,
327
+ text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`,
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ function getEnableDisableContext(
334
+ context: CommandContext,
335
+ argumentsString: string,
336
+ ): {
337
+ extensionManager: ExtensionManager;
338
+ names: string[];
339
+ scope: SettingScope;
340
+ } | null {
341
+ const extensionLoader =
342
+ context.services.agentContext?.config.getExtensionLoader();
343
+ if (!(extensionLoader instanceof ExtensionManager)) {
344
+ debugLogger.error(
345
+ `Cannot ${context.invocation?.name} extensions in this environment`,
346
+ );
347
+ return null;
348
+ }
349
+ const parts = argumentsString.split(' ');
350
+ const name = parts[0];
351
+ if (
352
+ name === '' ||
353
+ !(
354
+ (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope=<scope>
355
+ (parts.length === 3 && parts[1] === '--scope') // --scope <scope>
356
+ )
357
+ ) {
358
+ context.ui.addItem({
359
+ type: MessageType.ERROR,
360
+ text: `Usage: /extensions ${context.invocation?.name} <extension> [--scope=<user|workspace|session>]`,
361
+ });
362
+ return null;
363
+ }
364
+ let scope: SettingScope;
365
+ // Transform `--scope=<scope>` to `--scope <scope>`.
366
+ if (parts.length === 2) {
367
+ parts.push(...parts[1].split('='));
368
+ parts.splice(1, 1);
369
+ }
370
+ switch (parts[2].toLowerCase()) {
371
+ case 'workspace':
372
+ scope = SettingScope.Workspace;
373
+ break;
374
+ case 'user':
375
+ scope = SettingScope.User;
376
+ break;
377
+ case 'session':
378
+ scope = SettingScope.Session;
379
+ break;
380
+ default:
381
+ context.ui.addItem({
382
+ type: MessageType.ERROR,
383
+ text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`,
384
+ });
385
+ debugLogger.error();
386
+ return null;
387
+ }
388
+ let names: string[] = [];
389
+ if (name === '--all') {
390
+ let extensions = extensionLoader.getExtensions();
391
+ if (context.invocation?.name === 'enable') {
392
+ extensions = extensions.filter((ext) => !ext.isActive);
393
+ }
394
+ if (context.invocation?.name === 'disable') {
395
+ extensions = extensions.filter((ext) => ext.isActive);
396
+ }
397
+ names = extensions.map((ext) => ext.name);
398
+ } else {
399
+ names = [name];
400
+ }
401
+
402
+ return {
403
+ extensionManager: extensionLoader,
404
+ names,
405
+ scope,
406
+ };
407
+ }
408
+
409
+ async function disableAction(context: CommandContext, args: string) {
410
+ const enableContext = getEnableDisableContext(context, args);
411
+ if (!enableContext) return;
412
+
413
+ const { names, scope, extensionManager } = enableContext;
414
+ for (const name of names) {
415
+ await extensionManager.disableExtension(name, scope);
416
+ context.ui.addItem({
417
+ type: MessageType.INFO,
418
+ text: `Extension "${name}" disabled for the scope "${scope}"`,
419
+ });
420
+ }
421
+ }
422
+
423
+ async function enableAction(context: CommandContext, args: string) {
424
+ const enableContext = getEnableDisableContext(context, args);
425
+ if (!enableContext) return;
426
+
427
+ const { names, scope, extensionManager } = enableContext;
428
+ for (const name of names) {
429
+ await extensionManager.enableExtension(name, scope);
430
+ context.ui.addItem({
431
+ type: MessageType.INFO,
432
+ text: `Extension "${name}" enabled for the scope "${scope}"`,
433
+ });
434
+
435
+ // Auto-enable any disabled MCP servers for this extension
436
+ const extension = extensionManager
437
+ .getExtensions()
438
+ .find((e) => e.name === name);
439
+
440
+ if (extension?.mcpServers) {
441
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
442
+ const mcpClientManager =
443
+ context.services.agentContext?.config.getMcpClientManager();
444
+ const enabledServers = await mcpEnablementManager.autoEnableServers(
445
+ Object.keys(extension.mcpServers ?? {}),
446
+ );
447
+
448
+ if (mcpClientManager && enabledServers.length > 0) {
449
+ const restartPromises = enabledServers.map((serverName) =>
450
+ mcpClientManager.restartServer(serverName).catch((error) => {
451
+ context.ui.addItem({
452
+ type: MessageType.WARNING,
453
+ text: `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
454
+ });
455
+ }),
456
+ );
457
+ await Promise.all(restartPromises);
458
+ }
459
+
460
+ if (enabledServers.length > 0) {
461
+ context.ui.addItem({
462
+ type: MessageType.INFO,
463
+ text: `Re-enabled MCP servers: ${enabledServers.join(', ')}`,
464
+ });
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ async function installAction(
471
+ context: CommandContext,
472
+ args: string,
473
+ requestConsentOverride?: (consent: string) => Promise<boolean>,
474
+ ) {
475
+ const extensionLoader =
476
+ context.services.agentContext?.config.getExtensionLoader();
477
+ if (!(extensionLoader instanceof ExtensionManager)) {
478
+ debugLogger.error(
479
+ `Cannot ${context.invocation?.name} extensions in this environment`,
480
+ );
481
+ return;
482
+ }
483
+
484
+ const source = args.trim();
485
+ if (!source) {
486
+ context.ui.addItem({
487
+ type: MessageType.ERROR,
488
+ text: `Usage: /extensions install <source>`,
489
+ });
490
+ return;
491
+ }
492
+
493
+ // Validate that the source is either a valid URL or a valid file path.
494
+ let isValid = false;
495
+ try {
496
+ // Check if it's a valid URL.
497
+ new URL(source);
498
+ isValid = true;
499
+ } catch {
500
+ // If not a URL, check for characters that are disallowed in file paths
501
+ // and could be used for command injection.
502
+ if (!/[;&|`'"]/.test(source)) {
503
+ isValid = true;
504
+ }
505
+ }
506
+
507
+ if (!isValid) {
508
+ context.ui.addItem({
509
+ type: MessageType.ERROR,
510
+ text: `Invalid source: ${source}`,
511
+ });
512
+ return;
513
+ }
514
+
515
+ context.ui.addItem({
516
+ type: MessageType.INFO,
517
+ text: `Installing extension from "${source}"...`,
518
+ });
519
+
520
+ try {
521
+ const installMetadata = await inferInstallMetadata(source);
522
+ const extension = await extensionLoader.installOrUpdateExtension(
523
+ installMetadata,
524
+ undefined,
525
+ requestConsentOverride,
526
+ );
527
+ context.ui.addItem({
528
+ type: MessageType.INFO,
529
+ text: `Extension "${extension.name}" installed successfully.`,
530
+ });
531
+ } catch (error) {
532
+ context.ui.addItem({
533
+ type: MessageType.ERROR,
534
+ text: `Failed to install extension from "${source}": ${getErrorMessage(
535
+ error,
536
+ )}`,
537
+ });
538
+ }
539
+ }
540
+
541
+ async function linkAction(
542
+ context: CommandContext,
543
+ args: string,
544
+ requestConsentOverride?: (consent: string) => Promise<boolean>,
545
+ ) {
546
+ const extensionLoader =
547
+ context.services.agentContext?.config.getExtensionLoader();
548
+ if (!(extensionLoader instanceof ExtensionManager)) {
549
+ debugLogger.error(
550
+ `Cannot ${context.invocation?.name} extensions in this environment`,
551
+ );
552
+ return;
553
+ }
554
+
555
+ const sourceFilepath = args.trim();
556
+ if (!sourceFilepath) {
557
+ context.ui.addItem({
558
+ type: MessageType.ERROR,
559
+ text: `Usage: /extensions link <source>`,
560
+ });
561
+ return;
562
+ }
563
+ if (/[;&|`'"]/.test(sourceFilepath)) {
564
+ context.ui.addItem({
565
+ type: MessageType.ERROR,
566
+ text: `Source file path contains disallowed characters: ${sourceFilepath}`,
567
+ });
568
+ return;
569
+ }
570
+
571
+ try {
572
+ await stat(sourceFilepath);
573
+ } catch (error) {
574
+ context.ui.addItem({
575
+ type: MessageType.ERROR,
576
+ text: `Invalid source: ${sourceFilepath}`,
577
+ });
578
+ debugLogger.error(
579
+ `Failed to stat path "${sourceFilepath}": ${getErrorMessage(error)}`,
580
+ );
581
+ return;
582
+ }
583
+
584
+ context.ui.addItem({
585
+ type: MessageType.INFO,
586
+ text: `Linking extension from "${sourceFilepath}"...`,
587
+ });
588
+
589
+ try {
590
+ const installMetadata: ExtensionInstallMetadata = {
591
+ source: sourceFilepath,
592
+ type: 'link',
593
+ };
594
+ const extension = await extensionLoader.installOrUpdateExtension(
595
+ installMetadata,
596
+ undefined,
597
+ requestConsentOverride,
598
+ );
599
+ context.ui.addItem({
600
+ type: MessageType.INFO,
601
+ text: `Extension "${extension.name}" linked successfully.`,
602
+ });
603
+ } catch (error) {
604
+ context.ui.addItem({
605
+ type: MessageType.ERROR,
606
+ text: `Failed to link extension from "${sourceFilepath}": ${getErrorMessage(
607
+ error,
608
+ )}`,
609
+ });
610
+ }
611
+ }
612
+
613
+ async function uninstallAction(context: CommandContext, args: string) {
614
+ const extensionLoader =
615
+ context.services.agentContext?.config.getExtensionLoader();
616
+ if (!(extensionLoader instanceof ExtensionManager)) {
617
+ debugLogger.error(
618
+ `Cannot ${context.invocation?.name} extensions in this environment`,
619
+ );
620
+ return;
621
+ }
622
+
623
+ const uninstallArgs = args.split(' ').filter((value) => value.length > 0);
624
+ const all = uninstallArgs.includes('--all');
625
+ const names = uninstallArgs.filter((a) => !a.startsWith('--'));
626
+
627
+ if (!all && names.length === 0) {
628
+ context.ui.addItem({
629
+ type: MessageType.ERROR,
630
+ text: `Usage: /extensions uninstall <extension-names...>|--all`,
631
+ });
632
+ return;
633
+ }
634
+
635
+ let namesToUninstall: string[] = [];
636
+ if (all) {
637
+ namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
638
+ } else {
639
+ namesToUninstall = names;
640
+ }
641
+
642
+ if (namesToUninstall.length === 0) {
643
+ context.ui.addItem({
644
+ type: MessageType.INFO,
645
+ text: all ? 'No extensions installed.' : 'No extension name provided.',
646
+ });
647
+ return;
648
+ }
649
+
650
+ for (const extensionName of namesToUninstall) {
651
+ context.ui.addItem({
652
+ type: MessageType.INFO,
653
+ text: `Uninstalling extension "${extensionName}"...`,
654
+ });
655
+
656
+ try {
657
+ await extensionLoader.uninstallExtension(extensionName, false);
658
+ context.ui.addItem({
659
+ type: MessageType.INFO,
660
+ text: `Extension "${extensionName}" uninstalled successfully.`,
661
+ });
662
+ } catch (error) {
663
+ context.ui.addItem({
664
+ type: MessageType.ERROR,
665
+ text: `Failed to uninstall extension "${extensionName}": ${getErrorMessage(
666
+ error,
667
+ )}`,
668
+ });
669
+ }
670
+ }
671
+ }
672
+
673
+ async function configAction(context: CommandContext, args: string) {
674
+ const parts = args.trim().split(/\s+/).filter(Boolean);
675
+ let scope = ExtensionSettingScope.USER;
676
+
677
+ const scopeEqIndex = parts.findIndex((p) => p.startsWith('--scope='));
678
+ if (scopeEqIndex > -1) {
679
+ const scopeVal = parts[scopeEqIndex].split('=')[1];
680
+ if (scopeVal === 'workspace') {
681
+ scope = ExtensionSettingScope.WORKSPACE;
682
+ } else if (scopeVal === 'user') {
683
+ scope = ExtensionSettingScope.USER;
684
+ }
685
+ parts.splice(scopeEqIndex, 1);
686
+ } else {
687
+ const scopeIndex = parts.indexOf('--scope');
688
+ if (scopeIndex > -1) {
689
+ const scopeVal = parts[scopeIndex + 1];
690
+ if (scopeVal === 'workspace' || scopeVal === 'user') {
691
+ scope =
692
+ scopeVal === 'workspace'
693
+ ? ExtensionSettingScope.WORKSPACE
694
+ : ExtensionSettingScope.USER;
695
+ parts.splice(scopeIndex, 2);
696
+ }
697
+ }
698
+ }
699
+
700
+ const otherArgs = parts;
701
+ const name = otherArgs[0];
702
+ const setting = otherArgs[1];
703
+
704
+ if (name) {
705
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
706
+ context.ui.addItem({
707
+ type: MessageType.ERROR,
708
+ text: 'Invalid extension name. Names cannot contain path separators or "..".',
709
+ });
710
+ return;
711
+ }
712
+ }
713
+
714
+ const extensionManager =
715
+ context.services.agentContext?.config.getExtensionLoader();
716
+ if (!(extensionManager instanceof ExtensionManager)) {
717
+ debugLogger.error(
718
+ `Cannot ${context.invocation?.name} extensions in this environment`,
719
+ );
720
+ return;
721
+ }
722
+
723
+ const logger: ConfigLogger = {
724
+ log: (message: string) => {
725
+ context.ui.addItem({ type: MessageType.INFO, text: message.trim() });
726
+ },
727
+ error: (message: string) =>
728
+ context.ui.addItem({ type: MessageType.ERROR, text: message }),
729
+ };
730
+
731
+ return {
732
+ type: 'custom_dialog' as const,
733
+ component: React.createElement(ConfigExtensionDialog, {
734
+ extensionManager,
735
+ onClose: () => context.ui.removeComponent(),
736
+ extensionName: name,
737
+ settingKey: setting,
738
+ scope,
739
+ configureAll: !name && !setting,
740
+ loggerAdapter: logger,
741
+ }),
742
+ };
743
+ }
744
+
745
+ /**
746
+ * Exported for testing.
747
+ */
748
+ export function completeExtensions(
749
+ context: CommandContext,
750
+ partialArg: string,
751
+ ) {
752
+ let extensions = context.services.agentContext?.config.getExtensions() ?? [];
753
+
754
+ if (context.invocation?.name === 'enable') {
755
+ extensions = extensions.filter((ext) => !ext.isActive);
756
+ }
757
+ if (
758
+ context.invocation?.name === 'disable' ||
759
+ context.invocation?.name === 'restart' ||
760
+ context.invocation?.name === 'reload'
761
+ ) {
762
+ extensions = extensions.filter((ext) => ext.isActive);
763
+ }
764
+ const extensionNames = extensions.map((ext) => ext.name);
765
+ const suggestions = extensionNames.filter((name) =>
766
+ name.startsWith(partialArg),
767
+ );
768
+
769
+ if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
770
+ suggestions.unshift('--all');
771
+ }
772
+
773
+ return suggestions;
774
+ }
775
+
776
+ export function completeExtensionsAndScopes(
777
+ context: CommandContext,
778
+ partialArg: string,
779
+ ) {
780
+ return completeExtensions(context, partialArg).flatMap((s) => [
781
+ `${s} --scope user`,
782
+ `${s} --scope workspace`,
783
+ `${s} --scope session`,
784
+ ]);
785
+ }
786
+
787
+ const listExtensionsCommand: SlashCommand = {
788
+ name: 'list',
789
+ description: 'List active extensions',
790
+ kind: CommandKind.BUILT_IN,
791
+ autoExecute: true,
792
+ takesArgs: false,
793
+ action: listAction,
794
+ };
795
+
796
+ const updateExtensionsCommand: SlashCommand = {
797
+ name: 'update',
798
+ description: 'Update extensions. Usage: update <extension-names>|--all',
799
+ kind: CommandKind.BUILT_IN,
800
+ autoExecute: false,
801
+ action: updateAction,
802
+ completion: completeExtensions,
803
+ };
804
+
805
+ const disableCommand: SlashCommand = {
806
+ name: 'disable',
807
+ description: 'Disable an extension',
808
+ kind: CommandKind.BUILT_IN,
809
+ autoExecute: false,
810
+ action: disableAction,
811
+ completion: completeExtensionsAndScopes,
812
+ };
813
+
814
+ const enableCommand: SlashCommand = {
815
+ name: 'enable',
816
+ description: 'Enable an extension',
817
+ kind: CommandKind.BUILT_IN,
818
+ autoExecute: false,
819
+ action: enableAction,
820
+ completion: completeExtensionsAndScopes,
821
+ };
822
+
823
+ const installCommand: SlashCommand = {
824
+ name: 'install',
825
+ description: 'Install an extension from a git repo or local path',
826
+ kind: CommandKind.BUILT_IN,
827
+ autoExecute: false,
828
+ action: installAction,
829
+ };
830
+
831
+ const linkCommand: SlashCommand = {
832
+ name: 'link',
833
+ description: 'Link an extension from a local path',
834
+ kind: CommandKind.BUILT_IN,
835
+ autoExecute: false,
836
+ action: linkAction,
837
+ };
838
+
839
+ const uninstallCommand: SlashCommand = {
840
+ name: 'uninstall',
841
+ altNames: ['delete'],
842
+ description: 'Uninstall an extension',
843
+ kind: CommandKind.BUILT_IN,
844
+ autoExecute: false,
845
+ action: uninstallAction,
846
+ completion: completeExtensions,
847
+ };
848
+
849
+ const exploreExtensionsCommand: SlashCommand = {
850
+ name: 'explore',
851
+ description: 'Open extensions page in your browser',
852
+ kind: CommandKind.BUILT_IN,
853
+ autoExecute: true,
854
+ takesArgs: false,
855
+ action: exploreAction,
856
+ };
857
+
858
+ const reloadCommand: SlashCommand = {
859
+ name: 'reload',
860
+ altNames: ['restart'],
861
+ description: 'Reload all extensions',
862
+ kind: CommandKind.BUILT_IN,
863
+ autoExecute: false,
864
+ action: restartAction,
865
+ completion: completeExtensions,
866
+ };
867
+
868
+ const configCommand: SlashCommand = {
869
+ name: 'config',
870
+ description: 'Configure extension settings',
871
+ kind: CommandKind.BUILT_IN,
872
+ autoExecute: false,
873
+ action: configAction,
874
+ };
875
+
876
+ import { parseSlashCommand } from '../../utils/commands.js';
877
+
878
+ export function extensionsCommand(
879
+ enableExtensionReloading?: boolean,
880
+ ): SlashCommand {
881
+ const conditionalCommands = enableExtensionReloading
882
+ ? [
883
+ disableCommand,
884
+ enableCommand,
885
+ installCommand,
886
+ uninstallCommand,
887
+ linkCommand,
888
+ configCommand,
889
+ ]
890
+ : [];
891
+ const subCommands = [
892
+ listExtensionsCommand,
893
+ updateExtensionsCommand,
894
+ exploreExtensionsCommand,
895
+ reloadCommand,
896
+ ...conditionalCommands,
897
+ ];
898
+
899
+ return {
900
+ name: 'extensions',
901
+ description: 'Manage extensions',
902
+ kind: CommandKind.BUILT_IN,
903
+ autoExecute: false,
904
+ subCommands,
905
+ action: async (context, args) => {
906
+ if (args) {
907
+ const parsed = parseSlashCommand(`/${args}`, subCommands);
908
+ if (parsed.commandToExecute?.action) {
909
+ return parsed.commandToExecute.action(context, parsed.args);
910
+ }
911
+ }
912
+ // Default to list if no subcommand is provided
913
+ return listExtensionsCommand.action!(context, args);
914
+ },
915
+ };
916
+ }
packages/cli/src/ui/commands/footerCommand.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type SlashCommand,
9
+ type CommandContext,
10
+ type OpenCustomDialogActionReturn,
11
+ CommandKind,
12
+ } from './types.js';
13
+ import { FooterConfigDialog } from '../components/FooterConfigDialog.js';
14
+
15
+ export const footerCommand: SlashCommand = {
16
+ name: 'footer',
17
+ altNames: ['statusline'],
18
+ description: 'Configure which items appear in the footer (statusline)',
19
+ kind: CommandKind.BUILT_IN,
20
+ autoExecute: true,
21
+ action: (context: CommandContext): OpenCustomDialogActionReturn => ({
22
+ type: 'custom_dialog',
23
+ component: <FooterConfigDialog onClose={context.ui.removeComponent} />,
24
+ }),
25
+ };
packages/cli/src/ui/commands/gemmaStatusCommand.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { CommandKind, type SlashCommand } from './types.js';
8
+ import { MessageType, type HistoryItemGemmaStatus } from '../types.js';
9
+ import { checkGemmaStatus } from '../../commands/gemma/status.js';
10
+ import { GEMMA_MODEL_NAME } from '../../commands/gemma/constants.js';
11
+
12
+ export const gemmaStatusCommand: SlashCommand = {
13
+ name: 'gemma',
14
+ description: 'Check local Gemma model routing status',
15
+ kind: CommandKind.BUILT_IN,
16
+ autoExecute: true,
17
+ isSafeConcurrent: true,
18
+ action: async (context) => {
19
+ const port =
20
+ parseInt(
21
+ context.services.settings.merged.experimental?.gemmaModelRouter?.classifier?.host?.match(
22
+ /:(\d+)/,
23
+ )?.[1] ?? '',
24
+ 10,
25
+ ) || undefined;
26
+ const status = await checkGemmaStatus(port);
27
+ const item: Omit<HistoryItemGemmaStatus, 'id'> = {
28
+ type: MessageType.GEMMA_STATUS,
29
+ binaryInstalled: status.binaryInstalled,
30
+ binaryPath: status.binaryPath,
31
+ modelName: GEMMA_MODEL_NAME,
32
+ modelDownloaded: status.modelDownloaded,
33
+ serverRunning: status.serverRunning,
34
+ serverPid: status.serverPid,
35
+ serverPort: status.port,
36
+ settingsEnabled: status.settingsEnabled,
37
+ allPassing: status.allPassing,
38
+ };
39
+ context.ui.addItem(item);
40
+ },
41
+ };
packages/cli/src/ui/commands/helpCommand.test.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { helpCommand } from './helpCommand.js';
9
+ import { CommandKind, type CommandContext } from './types.js';
10
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
11
+ import { MessageType } from '../types.js';
12
+
13
+ describe('helpCommand', () => {
14
+ let mockContext: CommandContext;
15
+ const originalPlatform = process.platform;
16
+ const action = helpCommand.action;
17
+
18
+ if (!action) {
19
+ throw new Error('Help command has no action');
20
+ }
21
+
22
+ beforeEach(() => {
23
+ mockContext = createMockCommandContext({
24
+ ui: {
25
+ addItem: vi.fn(),
26
+ },
27
+ } as unknown as CommandContext);
28
+ });
29
+
30
+ afterEach(() => {
31
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
32
+ vi.unstubAllEnvs();
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ it('should add a help message to the UI history by default', async () => {
37
+ await action(mockContext, '');
38
+
39
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
40
+ expect.objectContaining({
41
+ type: MessageType.HELP,
42
+ timestamp: expect.any(Date),
43
+ }),
44
+ );
45
+ });
46
+
47
+ it('should have the correct command properties', () => {
48
+ expect(helpCommand.name).toBe('help');
49
+ expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
50
+ expect(helpCommand.description).toBe('For help on gemini-cli');
51
+ });
52
+
53
+ describe('Antigravity installer commands help', () => {
54
+ it('should output macOS installation command on darwin platform', async () => {
55
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
56
+
57
+ await action(mockContext, 'install antigravity cli');
58
+
59
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
60
+ expect.objectContaining({
61
+ type: MessageType.INFO,
62
+ text: `To install the Antigravity CLI on macOS, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
63
+ }),
64
+ );
65
+ });
66
+
67
+ it('should output Linux installation command on linux platform', async () => {
68
+ Object.defineProperty(process, 'platform', { value: 'linux' });
69
+
70
+ await action(mockContext, 'how do I install antigravity CLI');
71
+
72
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
73
+ expect.objectContaining({
74
+ type: MessageType.INFO,
75
+ text: `To install the Antigravity CLI on Linux, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
76
+ }),
77
+ );
78
+ });
79
+
80
+ it('should output Windows PowerShell installation command on win32 when PSModulePath is set', async () => {
81
+ Object.defineProperty(process, 'platform', { value: 'win32' });
82
+ vi.stubEnv('PSModulePath', 'C:\\some\\path');
83
+
84
+ await action(mockContext, 'how do I migrate to antigravity CLI');
85
+
86
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ type: MessageType.INFO,
89
+ text: `To install the Antigravity CLI on Windows (PowerShell), run the following command:\n\n'irm https://antigravity.google/cli/install.ps1 | iex'`,
90
+ }),
91
+ );
92
+ });
93
+
94
+ it('should output Windows CMD installation command on win32 when PSModulePath is not set', async () => {
95
+ Object.defineProperty(process, 'platform', { value: 'win32' });
96
+ vi.stubEnv('PSModulePath', '');
97
+
98
+ await action(mockContext, 'install antigravity cli');
99
+
100
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ type: MessageType.INFO,
103
+ text: `To install the Antigravity CLI on Windows (Command Prompt), run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd'`,
104
+ }),
105
+ );
106
+ });
107
+
108
+ it('should learn more message on unsupported platform', async () => {
109
+ Object.defineProperty(process, 'platform', { value: 'freebsd' });
110
+
111
+ await action(mockContext, 'install antigravity cli');
112
+
113
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
114
+ expect.objectContaining({
115
+ type: MessageType.INFO,
116
+ text: 'Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started',
117
+ }),
118
+ );
119
+ });
120
+
121
+ it('should fall back to default help if query does not contain install or migrate', async () => {
122
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
123
+
124
+ await action(mockContext, 'antigravity cli');
125
+
126
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
127
+ expect.objectContaining({
128
+ type: MessageType.HELP,
129
+ }),
130
+ );
131
+ });
132
+ });
133
+ });
packages/cli/src/ui/commands/helpCommand.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { CommandKind, type SlashCommand } from './types.js';
8
+ import { MessageType, type HistoryItemHelp } from '../types.js';
9
+ import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
10
+
11
+ export const helpCommand: SlashCommand = {
12
+ name: 'help',
13
+ kind: CommandKind.BUILT_IN,
14
+ description: 'For help on gemini-cli',
15
+ autoExecute: true,
16
+ action: async (context, args) => {
17
+ const lowerArgs = args?.toLowerCase() || '';
18
+ const hasAntigravity = lowerArgs.includes('antigravity');
19
+ const hasInstallOrMigrate =
20
+ lowerArgs.includes('install') || lowerArgs.includes('migrate');
21
+
22
+ if (hasAntigravity && hasInstallOrMigrate) {
23
+ const info = getAntigravityInstallInfo();
24
+
25
+ if (info) {
26
+ context.ui.addItem({
27
+ type: MessageType.INFO,
28
+ text: `To install the Antigravity CLI on ${info.platformName}, run the following command:\n\n'${info.installCmd}'`,
29
+ });
30
+ } else {
31
+ context.ui.addItem({
32
+ type: MessageType.INFO,
33
+ text: `Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started`,
34
+ });
35
+ }
36
+ return;
37
+ }
38
+
39
+ const helpItem: Omit<HistoryItemHelp, 'id'> = {
40
+ type: MessageType.HELP,
41
+ timestamp: new Date(),
42
+ };
43
+
44
+ context.ui.addItem(helpItem);
45
+ },
46
+ };
packages/cli/src/ui/commands/hooksCommand.test.ts ADDED
@@ -0,0 +1,835 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { hooksCommand } from './hooksCommand.js';
9
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
10
+ import {
11
+ HookType,
12
+ HookEventName,
13
+ ConfigSource,
14
+ type HookRegistryEntry,
15
+ } from '@google/gemini-cli-core';
16
+ import type { CommandContext } from './types.js';
17
+ import { SettingScope } from '../../config/settings.js';
18
+
19
+ describe('hooksCommand', () => {
20
+ let mockContext: CommandContext;
21
+ let mockHookSystem: {
22
+ getAllHooks: ReturnType<typeof vi.fn>;
23
+ setHookEnabled: ReturnType<typeof vi.fn>;
24
+ getRegistry: ReturnType<typeof vi.fn>;
25
+ };
26
+ let mockConfig: {
27
+ getHookSystem: ReturnType<typeof vi.fn>;
28
+ getEnableHooks: ReturnType<typeof vi.fn>;
29
+ updateDisabledHooks: ReturnType<typeof vi.fn>;
30
+ };
31
+ let mockSettings: {
32
+ merged: {
33
+ hooksConfig?: {
34
+ disabled?: string[];
35
+ };
36
+ };
37
+ setValue: ReturnType<typeof vi.fn>;
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ workspace: { path: string; settings: any };
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ user: { path: string; settings: any };
42
+ forScope: ReturnType<typeof vi.fn>;
43
+ };
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+
48
+ // Create mock hook system
49
+ mockHookSystem = {
50
+ getAllHooks: vi.fn().mockReturnValue([]),
51
+ setHookEnabled: vi.fn(),
52
+ getRegistry: vi.fn().mockReturnValue({
53
+ initialize: vi.fn().mockResolvedValue(undefined),
54
+ }),
55
+ };
56
+
57
+ // Create mock config
58
+ mockConfig = {
59
+ getHookSystem: vi.fn().mockReturnValue(mockHookSystem),
60
+ getEnableHooks: vi.fn().mockReturnValue(true),
61
+ updateDisabledHooks: vi.fn(),
62
+ };
63
+
64
+ // Create mock settings
65
+ const mockUser = {
66
+ path: '/mock/user.json',
67
+ settings: { hooksConfig: { disabled: [] } },
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ } as any;
70
+ const mockWorkspace = {
71
+ path: '/mock/workspace.json',
72
+ settings: { hooksConfig: { disabled: [] } },
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ } as any;
75
+
76
+ mockSettings = {
77
+ merged: {
78
+ hooksConfig: {
79
+ disabled: [],
80
+ },
81
+ },
82
+ setValue: vi.fn(),
83
+ workspace: mockWorkspace,
84
+ user: mockUser,
85
+ forScope: vi.fn((scope) => {
86
+ if (scope === SettingScope.User) return mockUser;
87
+ if (scope === SettingScope.Workspace) return mockWorkspace;
88
+ return mockUser;
89
+ }),
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ } as any;
92
+
93
+ // Create mock context with config and settings
94
+ mockContext = createMockCommandContext({
95
+ services: {
96
+ agentContext: { config: mockConfig },
97
+ settings: mockSettings,
98
+ },
99
+ });
100
+ });
101
+
102
+ afterEach(() => {
103
+ vi.restoreAllMocks();
104
+ });
105
+
106
+ describe('root command', () => {
107
+ it('should have the correct name and description', () => {
108
+ expect(hooksCommand.name).toBe('hooks');
109
+ expect(hooksCommand.description).toBe('Manage hooks');
110
+ });
111
+
112
+ it('should have all expected subcommands', () => {
113
+ expect(hooksCommand.subCommands).toBeDefined();
114
+ expect(hooksCommand.subCommands).toHaveLength(5);
115
+
116
+ const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name);
117
+ expect(subCommandNames).toContain('panel');
118
+ expect(subCommandNames).toContain('enable');
119
+ expect(subCommandNames).toContain('disable');
120
+ expect(subCommandNames).toContain('enable-all');
121
+ expect(subCommandNames).toContain('disable-all');
122
+ });
123
+
124
+ it('should delegate to panel action when invoked without subcommand', async () => {
125
+ if (!hooksCommand.action) {
126
+ throw new Error('hooks command must have an action');
127
+ }
128
+
129
+ mockHookSystem.getAllHooks.mockReturnValue([
130
+ createMockHook('test-hook', HookEventName.BeforeTool, true),
131
+ ]);
132
+
133
+ const result = await hooksCommand.action(mockContext, '');
134
+
135
+ expect(result).toHaveProperty('type', 'custom_dialog');
136
+ expect(result).toHaveProperty('component');
137
+ });
138
+ });
139
+
140
+ describe('panel subcommand', () => {
141
+ it('should return error when config is not loaded', async () => {
142
+ const contextWithoutConfig = createMockCommandContext({
143
+ services: {
144
+ agentContext: null,
145
+ },
146
+ });
147
+
148
+ const panelCmd = hooksCommand.subCommands!.find(
149
+ (cmd) => cmd.name === 'panel',
150
+ );
151
+ if (!panelCmd?.action) {
152
+ throw new Error('panel command must have an action');
153
+ }
154
+
155
+ const result = await panelCmd.action(contextWithoutConfig, '');
156
+
157
+ expect(result).toEqual({
158
+ type: 'message',
159
+ messageType: 'error',
160
+ content: 'Config not loaded.',
161
+ });
162
+ });
163
+
164
+ it('should return custom_dialog even when hook system is not enabled', async () => {
165
+ mockConfig.getHookSystem.mockReturnValue(null);
166
+
167
+ const panelCmd = hooksCommand.subCommands!.find(
168
+ (cmd) => cmd.name === 'panel',
169
+ );
170
+ if (!panelCmd?.action) {
171
+ throw new Error('panel command must have an action');
172
+ }
173
+
174
+ const result = await panelCmd.action(mockContext, '');
175
+
176
+ expect(result).toHaveProperty('type', 'custom_dialog');
177
+ expect(result).toHaveProperty('component');
178
+ });
179
+
180
+ it('should return custom_dialog when no hooks are configured', async () => {
181
+ mockHookSystem.getAllHooks.mockReturnValue([]);
182
+ (mockContext.services.settings.merged as Record<string, unknown>)[
183
+ 'hooksConfig'
184
+ ] = { enabled: true };
185
+
186
+ const panelCmd = hooksCommand.subCommands!.find(
187
+ (cmd) => cmd.name === 'panel',
188
+ );
189
+ if (!panelCmd?.action) {
190
+ throw new Error('panel command must have an action');
191
+ }
192
+
193
+ const result = await panelCmd.action(mockContext, '');
194
+
195
+ expect(result).toHaveProperty('type', 'custom_dialog');
196
+ expect(result).toHaveProperty('component');
197
+ });
198
+
199
+ it('should return custom_dialog when hooks are configured', async () => {
200
+ const mockHooks: HookRegistryEntry[] = [
201
+ createMockHook('echo-test', HookEventName.BeforeTool, true),
202
+ createMockHook('notify', HookEventName.AfterAgent, false),
203
+ ];
204
+
205
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
206
+ (mockContext.services.settings.merged as Record<string, unknown>)[
207
+ 'hooksConfig'
208
+ ] = { enabled: true };
209
+
210
+ const panelCmd = hooksCommand.subCommands!.find(
211
+ (cmd) => cmd.name === 'panel',
212
+ );
213
+ if (!panelCmd?.action) {
214
+ throw new Error('panel command must have an action');
215
+ }
216
+
217
+ const result = await panelCmd.action(mockContext, '');
218
+
219
+ expect(result).toHaveProperty('type', 'custom_dialog');
220
+ expect(result).toHaveProperty('component');
221
+ });
222
+ });
223
+
224
+ describe('enable subcommand', () => {
225
+ it('should return error when config is not loaded', async () => {
226
+ const contextWithoutConfig = createMockCommandContext({
227
+ services: {
228
+ agentContext: null,
229
+ },
230
+ });
231
+
232
+ const enableCmd = hooksCommand.subCommands!.find(
233
+ (cmd) => cmd.name === 'enable',
234
+ );
235
+ if (!enableCmd?.action) {
236
+ throw new Error('enable command must have an action');
237
+ }
238
+
239
+ const result = await enableCmd.action(contextWithoutConfig, 'test-hook');
240
+
241
+ expect(result).toEqual({
242
+ type: 'message',
243
+ messageType: 'error',
244
+ content: 'Config not loaded.',
245
+ });
246
+ });
247
+
248
+ it('should return error when hook system is not enabled', async () => {
249
+ mockConfig.getHookSystem.mockReturnValue(null);
250
+
251
+ const enableCmd = hooksCommand.subCommands!.find(
252
+ (cmd) => cmd.name === 'enable',
253
+ );
254
+ if (!enableCmd?.action) {
255
+ throw new Error('enable command must have an action');
256
+ }
257
+
258
+ const result = await enableCmd.action(mockContext, 'test-hook');
259
+
260
+ expect(result).toEqual({
261
+ type: 'message',
262
+ messageType: 'error',
263
+ content: 'Hook system is not enabled.',
264
+ });
265
+ });
266
+
267
+ it('should return error when hook name is not provided', async () => {
268
+ const enableCmd = hooksCommand.subCommands!.find(
269
+ (cmd) => cmd.name === 'enable',
270
+ );
271
+ if (!enableCmd?.action) {
272
+ throw new Error('enable command must have an action');
273
+ }
274
+
275
+ const result = await enableCmd.action(mockContext, '');
276
+
277
+ expect(result).toEqual({
278
+ type: 'message',
279
+ messageType: 'error',
280
+ content: 'Usage: /hooks enable <hook-name>',
281
+ });
282
+ });
283
+
284
+ it('should enable a hook and update settings', async () => {
285
+ // Update the user settings with disabled hooks
286
+ mockSettings.user.settings.hooksConfig.disabled = [
287
+ 'test-hook',
288
+ 'other-hook',
289
+ ];
290
+ mockSettings.workspace.settings.hooksConfig.disabled = [];
291
+
292
+ const enableCmd = hooksCommand.subCommands!.find(
293
+ (cmd) => cmd.name === 'enable',
294
+ );
295
+ if (!enableCmd?.action) {
296
+ throw new Error('enable command must have an action');
297
+ }
298
+
299
+ const result = await enableCmd.action(mockContext, 'test-hook');
300
+
301
+ expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
302
+ SettingScope.User,
303
+ 'hooksConfig.disabled',
304
+ ['other-hook'],
305
+ );
306
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
307
+ 'test-hook',
308
+ true,
309
+ );
310
+ expect(result).toEqual({
311
+ type: 'message',
312
+ messageType: 'info',
313
+ content:
314
+ 'Hook "test-hook" enabled by removing it from the disabled list in user (/mock/user.json) and workspace (/mock/workspace.json) settings.',
315
+ });
316
+ });
317
+
318
+ it('should complete hook names using friendly names', () => {
319
+ const enableCmd = hooksCommand.subCommands!.find(
320
+ (cmd) => cmd.name === 'enable',
321
+ )!;
322
+
323
+ const hookEntry = createMockHook(
324
+ './hooks/test.sh',
325
+ HookEventName.BeforeTool,
326
+ false, // Must be disabled for enable completion
327
+ );
328
+ hookEntry.config.name = 'friendly-name';
329
+
330
+ mockHookSystem.getAllHooks.mockReturnValue([hookEntry]);
331
+
332
+ const completions = enableCmd.completion!(mockContext, 'frie');
333
+ expect(completions).toContain('friendly-name');
334
+ });
335
+ });
336
+
337
+ describe('disable subcommand', () => {
338
+ it('should return error when config is not loaded', async () => {
339
+ const contextWithoutConfig = createMockCommandContext({
340
+ services: {
341
+ agentContext: null,
342
+ },
343
+ });
344
+
345
+ const disableCmd = hooksCommand.subCommands!.find(
346
+ (cmd) => cmd.name === 'disable',
347
+ );
348
+ if (!disableCmd?.action) {
349
+ throw new Error('disable command must have an action');
350
+ }
351
+
352
+ const result = await disableCmd.action(contextWithoutConfig, 'test-hook');
353
+
354
+ expect(result).toEqual({
355
+ type: 'message',
356
+ messageType: 'error',
357
+ content: 'Config not loaded.',
358
+ });
359
+ });
360
+
361
+ it('should return error when hook system is not enabled', async () => {
362
+ mockConfig.getHookSystem.mockReturnValue(null);
363
+
364
+ const disableCmd = hooksCommand.subCommands!.find(
365
+ (cmd) => cmd.name === 'disable',
366
+ );
367
+ if (!disableCmd?.action) {
368
+ throw new Error('disable command must have an action');
369
+ }
370
+
371
+ const result = await disableCmd.action(mockContext, 'test-hook');
372
+
373
+ expect(result).toEqual({
374
+ type: 'message',
375
+ messageType: 'error',
376
+ content: 'Hook system is not enabled.',
377
+ });
378
+ });
379
+
380
+ it('should return error when hook name is not provided', async () => {
381
+ const disableCmd = hooksCommand.subCommands!.find(
382
+ (cmd) => cmd.name === 'disable',
383
+ );
384
+ if (!disableCmd?.action) {
385
+ throw new Error('disable command must have an action');
386
+ }
387
+
388
+ const result = await disableCmd.action(mockContext, '');
389
+
390
+ expect(result).toEqual({
391
+ type: 'message',
392
+ messageType: 'error',
393
+ content: 'Usage: /hooks disable <hook-name>',
394
+ });
395
+ });
396
+
397
+ it('should disable a hook and update settings', async () => {
398
+ // Ensure not disabled anywhere
399
+ mockSettings.workspace.settings.hooksConfig.disabled = [];
400
+ mockSettings.user.settings.hooksConfig.disabled = [];
401
+
402
+ const disableCmd = hooksCommand.subCommands!.find(
403
+ (cmd) => cmd.name === 'disable',
404
+ );
405
+ if (!disableCmd?.action) {
406
+ throw new Error('disable command must have an action');
407
+ }
408
+
409
+ const result = await disableCmd.action(mockContext, 'test-hook');
410
+
411
+ // Should default to workspace if present
412
+ expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
413
+ SettingScope.Workspace,
414
+ 'hooksConfig.disabled',
415
+ ['test-hook'],
416
+ );
417
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
418
+ 'test-hook',
419
+ false,
420
+ );
421
+ expect(result).toEqual({
422
+ type: 'message',
423
+ messageType: 'info',
424
+ content:
425
+ 'Hook "test-hook" disabled by adding it to the disabled list in workspace (/mock/workspace.json) settings.',
426
+ });
427
+ });
428
+
429
+ it('should return info when hook is already disabled', async () => {
430
+ // Update the context's settings with the hook already disabled in Workspace
431
+ mockSettings.workspace.settings.hooksConfig.disabled = ['test-hook'];
432
+
433
+ const disableCmd = hooksCommand.subCommands!.find(
434
+ (cmd) => cmd.name === 'disable',
435
+ );
436
+ if (!disableCmd?.action) {
437
+ throw new Error('disable command must have an action');
438
+ }
439
+
440
+ const result = await disableCmd.action(mockContext, 'test-hook');
441
+
442
+ expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
443
+ expect(result).toEqual({
444
+ type: 'message',
445
+ messageType: 'info',
446
+ content: 'Hook "test-hook" is already disabled.',
447
+ });
448
+ });
449
+
450
+ it('should complete hook names using friendly names', () => {
451
+ const disableCmd = hooksCommand.subCommands!.find(
452
+ (cmd) => cmd.name === 'disable',
453
+ )!;
454
+
455
+ const hookEntry = createMockHook(
456
+ './hooks/test.sh',
457
+ HookEventName.BeforeTool,
458
+ true, // Must be enabled for disable completion
459
+ );
460
+ hookEntry.config.name = 'friendly-name';
461
+
462
+ mockHookSystem.getAllHooks.mockReturnValue([hookEntry]);
463
+
464
+ const completions = disableCmd.completion!(mockContext, 'frie');
465
+ expect(completions).toContain('friendly-name');
466
+ });
467
+ });
468
+
469
+ describe('completion', () => {
470
+ it('should return empty array when config is not available', () => {
471
+ const contextWithoutConfig = createMockCommandContext({
472
+ services: {
473
+ agentContext: null,
474
+ },
475
+ });
476
+
477
+ const enableCmd = hooksCommand.subCommands!.find(
478
+ (cmd) => cmd.name === 'enable',
479
+ );
480
+ if (!enableCmd?.completion) {
481
+ throw new Error('enable command must have completion');
482
+ }
483
+
484
+ const result = enableCmd.completion(contextWithoutConfig, 'test');
485
+ expect(result).toEqual([]);
486
+ });
487
+
488
+ it('should return empty array when hook system is not enabled', () => {
489
+ mockConfig.getHookSystem.mockReturnValue(null);
490
+
491
+ const enableCmd = hooksCommand.subCommands!.find(
492
+ (cmd) => cmd.name === 'enable',
493
+ );
494
+ if (!enableCmd?.completion) {
495
+ throw new Error('enable command must have completion');
496
+ }
497
+
498
+ const result = enableCmd.completion(mockContext, 'test');
499
+ expect(result).toEqual([]);
500
+ });
501
+
502
+ it('should return matching hook names based on status', () => {
503
+ const mockHooks: HookRegistryEntry[] = [
504
+ createMockHook('test-hook-enabled', HookEventName.BeforeTool, true),
505
+ createMockHook('test-hook-disabled', HookEventName.AfterTool, false),
506
+ ];
507
+
508
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
509
+
510
+ const enableCmd = hooksCommand.subCommands!.find(
511
+ (cmd) => cmd.name === 'enable',
512
+ )!;
513
+ const disableCmd = hooksCommand.subCommands!.find(
514
+ (cmd) => cmd.name === 'disable',
515
+ )!;
516
+
517
+ const enableResult = enableCmd.completion!(mockContext, 'test');
518
+ expect(enableResult).toEqual(['test-hook-disabled']);
519
+
520
+ const disableResult = disableCmd.completion!(mockContext, 'test');
521
+ expect(disableResult).toEqual(['test-hook-enabled']);
522
+ });
523
+
524
+ it('should return all relevant hook names when partial is empty', () => {
525
+ const mockHooks: HookRegistryEntry[] = [
526
+ createMockHook('hook-enabled', HookEventName.BeforeTool, true),
527
+ createMockHook('hook-disabled', HookEventName.AfterTool, false),
528
+ ];
529
+
530
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
531
+
532
+ const enableCmd = hooksCommand.subCommands!.find(
533
+ (cmd) => cmd.name === 'enable',
534
+ )!;
535
+ const disableCmd = hooksCommand.subCommands!.find(
536
+ (cmd) => cmd.name === 'disable',
537
+ )!;
538
+
539
+ expect(enableCmd.completion!(mockContext, '')).toEqual(['hook-disabled']);
540
+ expect(disableCmd.completion!(mockContext, '')).toEqual(['hook-enabled']);
541
+ });
542
+
543
+ it('should handle hooks without command name gracefully', () => {
544
+ const mockHooks: HookRegistryEntry[] = [
545
+ createMockHook('test-hook', HookEventName.BeforeTool, false),
546
+ {
547
+ ...createMockHook('', HookEventName.AfterTool, false),
548
+ config: { command: '', type: HookType.Command, timeout: 30 },
549
+ },
550
+ ];
551
+
552
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
553
+
554
+ const enableCmd = hooksCommand.subCommands!.find(
555
+ (cmd) => cmd.name === 'enable',
556
+ );
557
+ if (!enableCmd?.completion) {
558
+ throw new Error('enable command must have completion');
559
+ }
560
+
561
+ const result = enableCmd.completion(mockContext, 'test');
562
+ expect(result).toEqual(['test-hook']);
563
+ });
564
+ });
565
+
566
+ describe('enable-all subcommand', () => {
567
+ it('should return error when config is not loaded', async () => {
568
+ const contextWithoutConfig = createMockCommandContext({
569
+ services: {
570
+ agentContext: null,
571
+ },
572
+ });
573
+
574
+ const enableAllCmd = hooksCommand.subCommands!.find(
575
+ (cmd) => cmd.name === 'enable-all',
576
+ );
577
+ if (!enableAllCmd?.action) {
578
+ throw new Error('enable-all command must have an action');
579
+ }
580
+
581
+ const result = await enableAllCmd.action(contextWithoutConfig, '');
582
+
583
+ expect(result).toEqual({
584
+ type: 'message',
585
+ messageType: 'error',
586
+ content: 'Config not loaded.',
587
+ });
588
+ });
589
+
590
+ it('should return error when hook system is not enabled', async () => {
591
+ mockConfig.getHookSystem.mockReturnValue(null);
592
+
593
+ const enableAllCmd = hooksCommand.subCommands!.find(
594
+ (cmd) => cmd.name === 'enable-all',
595
+ );
596
+ if (!enableAllCmd?.action) {
597
+ throw new Error('enable-all command must have an action');
598
+ }
599
+
600
+ const result = await enableAllCmd.action(mockContext, '');
601
+
602
+ expect(result).toEqual({
603
+ type: 'message',
604
+ messageType: 'error',
605
+ content: 'Hook system is not enabled.',
606
+ });
607
+ });
608
+
609
+ it('should enable all disabled hooks', async () => {
610
+ const mockHooks = [
611
+ createMockHook('hook-1', HookEventName.BeforeTool, false),
612
+ createMockHook('hook-2', HookEventName.AfterTool, false),
613
+ createMockHook('hook-3', HookEventName.BeforeAgent, true), // already enabled
614
+ ];
615
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
616
+
617
+ const enableAllCmd = hooksCommand.subCommands!.find(
618
+ (cmd) => cmd.name === 'enable-all',
619
+ );
620
+ if (!enableAllCmd?.action) {
621
+ throw new Error('enable-all command must have an action');
622
+ }
623
+
624
+ const result = await enableAllCmd.action(mockContext, '');
625
+
626
+ expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
627
+ expect.any(String), // enableAll uses legacy logic so it might return 'Workspace' or 'User' depending on ternary
628
+ 'hooksConfig.disabled',
629
+ [],
630
+ );
631
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
632
+ 'hook-1',
633
+ true,
634
+ );
635
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
636
+ 'hook-2',
637
+ true,
638
+ );
639
+ expect(result).toEqual({
640
+ type: 'message',
641
+ messageType: 'info',
642
+ content: 'Enabled 2 hook(s) successfully.',
643
+ });
644
+ });
645
+
646
+ it('should return info when no hooks are configured', async () => {
647
+ mockHookSystem.getAllHooks.mockReturnValue([]);
648
+
649
+ const enableAllCmd = hooksCommand.subCommands!.find(
650
+ (cmd) => cmd.name === 'enable-all',
651
+ );
652
+ if (!enableAllCmd?.action) {
653
+ throw new Error('enable-all command must have an action');
654
+ }
655
+
656
+ const result = await enableAllCmd.action(mockContext, '');
657
+
658
+ expect(result).toEqual({
659
+ type: 'message',
660
+ messageType: 'info',
661
+ content: 'No hooks configured.',
662
+ });
663
+ });
664
+
665
+ it('should return info when all hooks are already enabled', async () => {
666
+ const mockHooks = [
667
+ createMockHook('hook-1', HookEventName.BeforeTool, true),
668
+ createMockHook('hook-2', HookEventName.AfterTool, true),
669
+ ];
670
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
671
+
672
+ const enableAllCmd = hooksCommand.subCommands!.find(
673
+ (cmd) => cmd.name === 'enable-all',
674
+ );
675
+ if (!enableAllCmd?.action) {
676
+ throw new Error('enable-all command must have an action');
677
+ }
678
+
679
+ const result = await enableAllCmd.action(mockContext, '');
680
+
681
+ expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
682
+ expect(result).toEqual({
683
+ type: 'message',
684
+ messageType: 'info',
685
+ content: 'All hooks are already enabled.',
686
+ });
687
+ });
688
+ });
689
+
690
+ describe('disable-all subcommand', () => {
691
+ it('should return error when config is not loaded', async () => {
692
+ const contextWithoutConfig = createMockCommandContext({
693
+ services: {
694
+ agentContext: null,
695
+ },
696
+ });
697
+
698
+ const disableAllCmd = hooksCommand.subCommands!.find(
699
+ (cmd) => cmd.name === 'disable-all',
700
+ );
701
+ if (!disableAllCmd?.action) {
702
+ throw new Error('disable-all command must have an action');
703
+ }
704
+
705
+ const result = await disableAllCmd.action(contextWithoutConfig, '');
706
+
707
+ expect(result).toEqual({
708
+ type: 'message',
709
+ messageType: 'error',
710
+ content: 'Config not loaded.',
711
+ });
712
+ });
713
+
714
+ it('should return error when hook system is not enabled', async () => {
715
+ mockConfig.getHookSystem.mockReturnValue(null);
716
+
717
+ const disableAllCmd = hooksCommand.subCommands!.find(
718
+ (cmd) => cmd.name === 'disable-all',
719
+ );
720
+ if (!disableAllCmd?.action) {
721
+ throw new Error('disable-all command must have an action');
722
+ }
723
+
724
+ const result = await disableAllCmd.action(mockContext, '');
725
+
726
+ expect(result).toEqual({
727
+ type: 'message',
728
+ messageType: 'error',
729
+ content: 'Hook system is not enabled.',
730
+ });
731
+ });
732
+
733
+ it('should disable all enabled hooks', async () => {
734
+ const mockHooks = [
735
+ createMockHook('hook-1', HookEventName.BeforeTool, true),
736
+ createMockHook('hook-2', HookEventName.AfterTool, true),
737
+ createMockHook('hook-3', HookEventName.BeforeAgent, false), // already disabled
738
+ ];
739
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
740
+
741
+ const disableAllCmd = hooksCommand.subCommands!.find(
742
+ (cmd) => cmd.name === 'disable-all',
743
+ );
744
+ if (!disableAllCmd?.action) {
745
+ throw new Error('disable-all command must have an action');
746
+ }
747
+
748
+ const result = await disableAllCmd.action(mockContext, '');
749
+
750
+ expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
751
+ expect.any(String),
752
+ 'hooksConfig.disabled',
753
+ ['hook-1', 'hook-2', 'hook-3'],
754
+ );
755
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
756
+ 'hook-1',
757
+ false,
758
+ );
759
+ expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
760
+ 'hook-2',
761
+ false,
762
+ );
763
+ expect(result).toEqual({
764
+ type: 'message',
765
+ messageType: 'info',
766
+ content: 'Disabled 2 hook(s) successfully.',
767
+ });
768
+ });
769
+
770
+ it('should return info when no hooks are configured', async () => {
771
+ mockHookSystem.getAllHooks.mockReturnValue([]);
772
+
773
+ const disableAllCmd = hooksCommand.subCommands!.find(
774
+ (cmd) => cmd.name === 'disable-all',
775
+ );
776
+ if (!disableAllCmd?.action) {
777
+ throw new Error('disable-all command must have an action');
778
+ }
779
+
780
+ const result = await disableAllCmd.action(mockContext, '');
781
+
782
+ expect(result).toEqual({
783
+ type: 'message',
784
+ messageType: 'info',
785
+ content: 'No hooks configured.',
786
+ });
787
+ });
788
+
789
+ it('should return info when all hooks are already disabled', async () => {
790
+ const mockHooks = [
791
+ createMockHook('hook-1', HookEventName.BeforeTool, false),
792
+ createMockHook('hook-2', HookEventName.AfterTool, false),
793
+ ];
794
+ mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
795
+
796
+ const disableAllCmd = hooksCommand.subCommands!.find(
797
+ (cmd) => cmd.name === 'disable-all',
798
+ );
799
+ if (!disableAllCmd?.action) {
800
+ throw new Error('disable-all command must have an action');
801
+ }
802
+
803
+ const result = await disableAllCmd.action(mockContext, '');
804
+
805
+ expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
806
+ expect(result).toEqual({
807
+ type: 'message',
808
+ messageType: 'info',
809
+ content: 'All hooks are already disabled.',
810
+ });
811
+ });
812
+ });
813
+ });
814
+
815
+ /**
816
+ * Helper function to create a mock HookRegistryEntry
817
+ */
818
+ function createMockHook(
819
+ command: string,
820
+ eventName: HookEventName,
821
+ enabled: boolean,
822
+ ): HookRegistryEntry {
823
+ return {
824
+ config: {
825
+ command,
826
+ type: HookType.Command,
827
+ timeout: 30,
828
+ },
829
+ source: ConfigSource.Project,
830
+ eventName,
831
+ matcher: undefined,
832
+ sequential: false,
833
+ enabled,
834
+ };
835
+ }
packages/cli/src/ui/commands/hooksCommand.ts ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { createElement } from 'react';
8
+ import type {
9
+ SlashCommand,
10
+ CommandContext,
11
+ OpenCustomDialogActionReturn,
12
+ } from './types.js';
13
+ import { CommandKind } from './types.js';
14
+ import type {
15
+ HookRegistryEntry,
16
+ MessageActionReturn,
17
+ } from '@google/gemini-cli-core';
18
+ import { getErrorMessage } from '@google/gemini-cli-core';
19
+ import { SettingScope, isLoadableSettingScope } from '../../config/settings.js';
20
+ import { enableHook, disableHook } from '../../utils/hookSettings.js';
21
+ import { renderHookActionFeedback } from '../../utils/hookUtils.js';
22
+ import { HooksDialog } from '../components/HooksDialog.js';
23
+
24
+ /**
25
+ * Display a formatted list of hooks with their status in a dialog
26
+ */
27
+ function panelAction(
28
+ context: CommandContext,
29
+ ): MessageActionReturn | OpenCustomDialogActionReturn {
30
+ const agentContext = context.services.agentContext;
31
+ const config = agentContext?.config;
32
+ if (!config) {
33
+ return {
34
+ type: 'message',
35
+ messageType: 'error',
36
+ content: 'Config not loaded.',
37
+ };
38
+ }
39
+
40
+ const hookSystem = config.getHookSystem();
41
+ const allHooks = hookSystem?.getAllHooks() || [];
42
+
43
+ return {
44
+ type: 'custom_dialog',
45
+ component: createElement(HooksDialog, {
46
+ hooks: allHooks,
47
+ onClose: () => context.ui.removeComponent(),
48
+ }),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Enable a hook by name
54
+ */
55
+ async function enableAction(
56
+ context: CommandContext,
57
+ args: string,
58
+ ): Promise<void | MessageActionReturn> {
59
+ const agentContext = context.services.agentContext;
60
+ const config = agentContext?.config;
61
+ if (!config) {
62
+ return {
63
+ type: 'message',
64
+ messageType: 'error',
65
+ content: 'Config not loaded.',
66
+ };
67
+ }
68
+
69
+ const hookSystem = config.getHookSystem();
70
+ if (!hookSystem) {
71
+ return {
72
+ type: 'message',
73
+ messageType: 'error',
74
+ content: 'Hook system is not enabled.',
75
+ };
76
+ }
77
+
78
+ const hookName = args.trim();
79
+ if (!hookName) {
80
+ return {
81
+ type: 'message',
82
+ messageType: 'error',
83
+ content: 'Usage: /hooks enable <hook-name>',
84
+ };
85
+ }
86
+
87
+ const settings = context.services.settings;
88
+ const result = enableHook(settings, hookName);
89
+
90
+ if (result.status === 'success') {
91
+ hookSystem.setHookEnabled(hookName, true);
92
+ }
93
+
94
+ const feedback = renderHookActionFeedback(
95
+ result,
96
+ (label, path) => `${label} (${path})`,
97
+ );
98
+
99
+ return {
100
+ type: 'message',
101
+ messageType: result.status === 'error' ? 'error' : 'info',
102
+ content: feedback,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Disable a hook by name
108
+ */
109
+ async function disableAction(
110
+ context: CommandContext,
111
+ args: string,
112
+ ): Promise<void | MessageActionReturn> {
113
+ const agentContext = context.services.agentContext;
114
+ const config = agentContext?.config;
115
+ if (!config) {
116
+ return {
117
+ type: 'message',
118
+ messageType: 'error',
119
+ content: 'Config not loaded.',
120
+ };
121
+ }
122
+
123
+ const hookSystem = config.getHookSystem();
124
+ if (!hookSystem) {
125
+ return {
126
+ type: 'message',
127
+ messageType: 'error',
128
+ content: 'Hook system is not enabled.',
129
+ };
130
+ }
131
+
132
+ const hookName = args.trim();
133
+ if (!hookName) {
134
+ return {
135
+ type: 'message',
136
+ messageType: 'error',
137
+ content: 'Usage: /hooks disable <hook-name>',
138
+ };
139
+ }
140
+
141
+ const settings = context.services.settings;
142
+ const scope = settings.workspace ? SettingScope.Workspace : SettingScope.User;
143
+
144
+ const result = disableHook(settings, hookName, scope);
145
+
146
+ if (result.status === 'success') {
147
+ hookSystem.setHookEnabled(hookName, false);
148
+ }
149
+
150
+ const feedback = renderHookActionFeedback(
151
+ result,
152
+ (label, path) => `${label} (${path})`,
153
+ );
154
+
155
+ return {
156
+ type: 'message',
157
+ messageType: result.status === 'error' ? 'error' : 'info',
158
+ content: feedback,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Completion function for enabled hook names (to be disabled)
164
+ */
165
+ function completeEnabledHookNames(
166
+ context: CommandContext,
167
+ partialArg: string,
168
+ ): string[] {
169
+ const agentContext = context.services.agentContext;
170
+ const config = agentContext?.config;
171
+ if (!config) return [];
172
+
173
+ const hookSystem = config.getHookSystem();
174
+ if (!hookSystem) return [];
175
+
176
+ const allHooks = hookSystem.getAllHooks();
177
+ return allHooks
178
+ .filter((hook) => hook.enabled)
179
+ .map((hook) => getHookDisplayName(hook))
180
+ .filter((name) => name.startsWith(partialArg));
181
+ }
182
+
183
+ /**
184
+ * Completion function for disabled hook names (to be enabled)
185
+ */
186
+ function completeDisabledHookNames(
187
+ context: CommandContext,
188
+ partialArg: string,
189
+ ): string[] {
190
+ const agentContext = context.services.agentContext;
191
+ const config = agentContext?.config;
192
+ if (!config) return [];
193
+
194
+ const hookSystem = config.getHookSystem();
195
+ if (!hookSystem) return [];
196
+
197
+ const allHooks = hookSystem.getAllHooks();
198
+ return allHooks
199
+ .filter((hook) => !hook.enabled)
200
+ .map((hook) => getHookDisplayName(hook))
201
+ .filter((name) => name.startsWith(partialArg));
202
+ }
203
+
204
+ /**
205
+ * Get a display name for a hook
206
+ */
207
+ function getHookDisplayName(hook: HookRegistryEntry): string {
208
+ return hook.config.name || hook.config.command || 'unknown-hook';
209
+ }
210
+
211
+ /**
212
+ * Enable all hooks by clearing the disabled list
213
+ */
214
+ async function enableAllAction(
215
+ context: CommandContext,
216
+ ): Promise<void | MessageActionReturn> {
217
+ const agentContext = context.services.agentContext;
218
+ const config = agentContext?.config;
219
+ if (!config) {
220
+ return {
221
+ type: 'message',
222
+ messageType: 'error',
223
+ content: 'Config not loaded.',
224
+ };
225
+ }
226
+
227
+ const hookSystem = config.getHookSystem();
228
+ if (!hookSystem) {
229
+ return {
230
+ type: 'message',
231
+ messageType: 'error',
232
+ content: 'Hook system is not enabled.',
233
+ };
234
+ }
235
+
236
+ const settings = context.services.settings;
237
+ const allHooks = hookSystem.getAllHooks();
238
+
239
+ if (allHooks.length === 0) {
240
+ return {
241
+ type: 'message',
242
+ messageType: 'info',
243
+ content: 'No hooks configured.',
244
+ };
245
+ }
246
+
247
+ const disabledHooks = allHooks.filter((hook) => !hook.enabled);
248
+ if (disabledHooks.length === 0) {
249
+ return {
250
+ type: 'message',
251
+ messageType: 'info',
252
+ content: 'All hooks are already enabled.',
253
+ };
254
+ }
255
+
256
+ try {
257
+ const scopes = [SettingScope.Workspace, SettingScope.User];
258
+ for (const scope of scopes) {
259
+ if (isLoadableSettingScope(scope)) {
260
+ settings.setValue(scope, 'hooksConfig.disabled', []);
261
+ }
262
+ }
263
+
264
+ for (const hook of disabledHooks) {
265
+ const hookName = getHookDisplayName(hook);
266
+ hookSystem.setHookEnabled(hookName, true);
267
+ }
268
+
269
+ return {
270
+ type: 'message',
271
+ messageType: 'info',
272
+ content: `Enabled ${disabledHooks.length} hook(s) successfully.`,
273
+ };
274
+ } catch (error) {
275
+ return {
276
+ type: 'message',
277
+ messageType: 'error',
278
+ content: `Failed to enable hooks: ${getErrorMessage(error)}`,
279
+ };
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Disable all hooks by adding all hooks to the disabled list
285
+ */
286
+ async function disableAllAction(
287
+ context: CommandContext,
288
+ ): Promise<void | MessageActionReturn> {
289
+ const agentContext = context.services.agentContext;
290
+ const config = agentContext?.config;
291
+ if (!config) {
292
+ return {
293
+ type: 'message',
294
+ messageType: 'error',
295
+ content: 'Config not loaded.',
296
+ };
297
+ }
298
+
299
+ const hookSystem = config.getHookSystem();
300
+ if (!hookSystem) {
301
+ return {
302
+ type: 'message',
303
+ messageType: 'error',
304
+ content: 'Hook system is not enabled.',
305
+ };
306
+ }
307
+
308
+ const settings = context.services.settings;
309
+ const allHooks = hookSystem.getAllHooks();
310
+
311
+ if (allHooks.length === 0) {
312
+ return {
313
+ type: 'message',
314
+ messageType: 'info',
315
+ content: 'No hooks configured.',
316
+ };
317
+ }
318
+
319
+ const enabledHooks = allHooks.filter((hook) => hook.enabled);
320
+ if (enabledHooks.length === 0) {
321
+ return {
322
+ type: 'message',
323
+ messageType: 'info',
324
+ content: 'All hooks are already disabled.',
325
+ };
326
+ }
327
+
328
+ try {
329
+ const allHookNames = allHooks.map((hook) => getHookDisplayName(hook));
330
+ const scope = settings.workspace
331
+ ? SettingScope.Workspace
332
+ : SettingScope.User;
333
+ settings.setValue(scope, 'hooksConfig.disabled', allHookNames);
334
+
335
+ for (const hook of enabledHooks) {
336
+ const hookName = getHookDisplayName(hook);
337
+ hookSystem.setHookEnabled(hookName, false);
338
+ }
339
+
340
+ return {
341
+ type: 'message',
342
+ messageType: 'info',
343
+ content: `Disabled ${enabledHooks.length} hook(s) successfully.`,
344
+ };
345
+ } catch (error) {
346
+ return {
347
+ type: 'message',
348
+ messageType: 'error',
349
+ content: `Failed to disable hooks: ${getErrorMessage(error)}`,
350
+ };
351
+ }
352
+ }
353
+
354
+ const panelCommand: SlashCommand = {
355
+ name: 'panel',
356
+ altNames: ['list', 'show'],
357
+ description: 'Display all registered hooks with their status',
358
+ kind: CommandKind.BUILT_IN,
359
+ autoExecute: true,
360
+ action: panelAction,
361
+ };
362
+
363
+ const enableCommand: SlashCommand = {
364
+ name: 'enable',
365
+ description: 'Enable a hook by name',
366
+ kind: CommandKind.BUILT_IN,
367
+ autoExecute: true,
368
+ action: enableAction,
369
+ completion: completeDisabledHookNames,
370
+ };
371
+
372
+ const disableCommand: SlashCommand = {
373
+ name: 'disable',
374
+ description: 'Disable a hook by name',
375
+ kind: CommandKind.BUILT_IN,
376
+ autoExecute: true,
377
+ action: disableAction,
378
+ completion: completeEnabledHookNames,
379
+ };
380
+
381
+ const enableAllCommand: SlashCommand = {
382
+ name: 'enable-all',
383
+ altNames: ['enableall'],
384
+ description: 'Enable all disabled hooks',
385
+ kind: CommandKind.BUILT_IN,
386
+ autoExecute: true,
387
+ action: enableAllAction,
388
+ };
389
+
390
+ const disableAllCommand: SlashCommand = {
391
+ name: 'disable-all',
392
+ altNames: ['disableall'],
393
+ description: 'Disable all enabled hooks',
394
+ kind: CommandKind.BUILT_IN,
395
+ autoExecute: true,
396
+ action: disableAllAction,
397
+ };
398
+
399
+ export const hooksCommand: SlashCommand = {
400
+ name: 'hooks',
401
+ description: 'Manage hooks',
402
+ kind: CommandKind.BUILT_IN,
403
+ subCommands: [
404
+ panelCommand,
405
+ enableCommand,
406
+ disableCommand,
407
+ enableAllCommand,
408
+ disableAllCommand,
409
+ ],
410
+ action: (context: CommandContext) => panelCommand.action!(context, ''),
411
+ };
packages/cli/src/ui/commands/ideCommand.test.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ vi,
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ type MockInstance,
15
+ } from 'vitest';
16
+ import { ideCommand } from './ideCommand.js';
17
+ import { type CommandContext } from './types.js';
18
+ import { IDE_DEFINITIONS } from '@google/gemini-cli-core';
19
+ import * as core from '@google/gemini-cli-core';
20
+
21
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
22
+ const original = await importOriginal<typeof core>();
23
+ return {
24
+ ...original,
25
+ getOauthClient: vi.fn(original.getOauthClient),
26
+ getIdeInstaller: vi.fn(original.getIdeInstaller),
27
+ IdeClient: {
28
+ getInstance: vi.fn(),
29
+ },
30
+ };
31
+ });
32
+
33
+ describe('ideCommand', () => {
34
+ let mockContext: CommandContext;
35
+ let mockIdeClient: core.IdeClient;
36
+ let platformSpy: MockInstance;
37
+
38
+ beforeEach(() => {
39
+ vi.resetAllMocks();
40
+
41
+ mockIdeClient = {
42
+ reconnect: vi.fn(),
43
+ disconnect: vi.fn(),
44
+ connect: vi.fn(),
45
+ getCurrentIde: vi.fn(),
46
+ getConnectionStatus: vi.fn(),
47
+ getDetectedIdeDisplayName: vi.fn(),
48
+ } as unknown as core.IdeClient;
49
+
50
+ vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient);
51
+ vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue(
52
+ 'VS Code',
53
+ );
54
+
55
+ mockContext = {
56
+ ui: {
57
+ addItem: vi.fn(),
58
+ },
59
+ services: {
60
+ settings: {
61
+ setValue: vi.fn(),
62
+ },
63
+ agentContext: {
64
+ config: {
65
+ getIdeMode: vi.fn(),
66
+ setIdeMode: vi.fn(),
67
+ getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
68
+ },
69
+ },
70
+ },
71
+ } as unknown as CommandContext;
72
+
73
+ platformSpy = vi.spyOn(process, 'platform', 'get');
74
+ });
75
+
76
+ afterEach(() => {
77
+ vi.restoreAllMocks();
78
+ });
79
+
80
+ it('should return the ide command', async () => {
81
+ vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
82
+ IDE_DEFINITIONS.vscode,
83
+ );
84
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
85
+ status: core.IDEConnectionStatus.Disconnected,
86
+ });
87
+ const command = await ideCommand();
88
+ expect(command).not.toBeNull();
89
+ expect(command.name).toBe('ide');
90
+ expect(command.subCommands).toHaveLength(3);
91
+ expect(command.subCommands?.[0].name).toBe('enable');
92
+ expect(command.subCommands?.[1].name).toBe('status');
93
+ expect(command.subCommands?.[2].name).toBe('install');
94
+ });
95
+
96
+ it('should show disable command when connected', async () => {
97
+ vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
98
+ IDE_DEFINITIONS.vscode,
99
+ );
100
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
101
+ status: core.IDEConnectionStatus.Connected,
102
+ });
103
+ const command = await ideCommand();
104
+ expect(command).not.toBeNull();
105
+ const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
106
+ expect(subCommandNames).toContain('disable');
107
+ expect(subCommandNames).not.toContain('enable');
108
+ });
109
+
110
+ describe('status subcommand', () => {
111
+ beforeEach(() => {
112
+ vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
113
+ IDE_DEFINITIONS.vscode,
114
+ );
115
+ });
116
+
117
+ it('should show connected status', async () => {
118
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
119
+ status: core.IDEConnectionStatus.Connected,
120
+ });
121
+ const command = await ideCommand();
122
+ const result = await command.subCommands!.find(
123
+ (c) => c.name === 'status',
124
+ )!.action!(mockContext, '');
125
+ expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
126
+ expect(result).toEqual({
127
+ type: 'message',
128
+ messageType: 'info',
129
+ content: '🟢 Connected to VS Code',
130
+ });
131
+ });
132
+
133
+ it('should show connecting status', async () => {
134
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
135
+ status: core.IDEConnectionStatus.Connecting,
136
+ });
137
+ const command = await ideCommand();
138
+ const result = await command.subCommands!.find(
139
+ (c) => c.name === 'status',
140
+ )!.action!(mockContext, '');
141
+ expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
142
+ expect(result).toEqual({
143
+ type: 'message',
144
+ messageType: 'info',
145
+ content: `🟡 Connecting...`,
146
+ });
147
+ });
148
+ it('should show disconnected status', async () => {
149
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
150
+ status: core.IDEConnectionStatus.Disconnected,
151
+ });
152
+ const command = await ideCommand();
153
+ const result = await command.subCommands!.find(
154
+ (c) => c.name === 'status',
155
+ )!.action!(mockContext, '');
156
+ expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
157
+ expect(result).toEqual({
158
+ type: 'message',
159
+ messageType: 'error',
160
+ content: `🔴 Disconnected`,
161
+ });
162
+ });
163
+
164
+ it('should show disconnected status with details', async () => {
165
+ const details = 'Something went wrong';
166
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
167
+ status: core.IDEConnectionStatus.Disconnected,
168
+ details,
169
+ });
170
+ const command = await ideCommand();
171
+ const result = await command.subCommands!.find(
172
+ (c) => c.name === 'status',
173
+ )!.action!(mockContext, '');
174
+ expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
175
+ expect(result).toEqual({
176
+ type: 'message',
177
+ messageType: 'error',
178
+ content: `🔴 Disconnected: ${details}`,
179
+ });
180
+ });
181
+ });
182
+
183
+ describe('install subcommand', () => {
184
+ const mockInstall = vi.fn();
185
+ beforeEach(() => {
186
+ vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
187
+ IDE_DEFINITIONS.vscode,
188
+ );
189
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
190
+ status: core.IDEConnectionStatus.Disconnected,
191
+ });
192
+ vi.mocked(core.getIdeInstaller).mockReturnValue({
193
+ install: mockInstall,
194
+ });
195
+ platformSpy.mockReturnValue('linux');
196
+ });
197
+
198
+ it('should install the extension', async () => {
199
+ vi.useFakeTimers();
200
+ mockInstall.mockResolvedValue({
201
+ success: true,
202
+ message: 'Successfully installed.',
203
+ });
204
+
205
+ const command = await ideCommand();
206
+
207
+ // For the polling loop inside the action.
208
+ vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
209
+ status: core.IDEConnectionStatus.Connected,
210
+ });
211
+
212
+ const actionPromise = command.subCommands!.find(
213
+ (c) => c.name === 'install',
214
+ )!.action!(mockContext, '');
215
+ await vi.runAllTimersAsync();
216
+ await actionPromise;
217
+
218
+ expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
219
+ expect(mockInstall).toHaveBeenCalled();
220
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
221
+ expect.objectContaining({
222
+ type: 'info',
223
+ text: `Installing IDE companion...`,
224
+ }),
225
+ expect.any(Number),
226
+ );
227
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
228
+ expect.objectContaining({
229
+ type: 'info',
230
+ text: 'Successfully installed.',
231
+ }),
232
+ expect.any(Number),
233
+ );
234
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ type: 'info',
237
+ text: '🟢 Connected to VS Code',
238
+ }),
239
+ expect.any(Number),
240
+ );
241
+ vi.useRealTimers();
242
+ }, 10000);
243
+
244
+ it('should show an error if installation fails', async () => {
245
+ mockInstall.mockResolvedValue({
246
+ success: false,
247
+ message: 'Installation failed.',
248
+ });
249
+
250
+ const command = await ideCommand();
251
+ await command.subCommands!.find((c) => c.name === 'install')!.action!(
252
+ mockContext,
253
+ '',
254
+ );
255
+
256
+ expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
257
+ expect(mockInstall).toHaveBeenCalled();
258
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
259
+ expect.objectContaining({
260
+ type: 'info',
261
+ text: `Installing IDE companion...`,
262
+ }),
263
+ expect.any(Number),
264
+ );
265
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
266
+ expect.objectContaining({
267
+ type: 'error',
268
+ text: 'Installation failed.',
269
+ }),
270
+ expect.any(Number),
271
+ );
272
+ });
273
+ });
274
+ });
packages/cli/src/ui/commands/ideCommand.ts ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type Config,
9
+ IdeClient,
10
+ type File,
11
+ logIdeConnection,
12
+ IdeConnectionEvent,
13
+ IdeConnectionType,
14
+ } from '@google/gemini-cli-core';
15
+ import {
16
+ getIdeInstaller,
17
+ IDEConnectionStatus,
18
+ ideContextStore,
19
+ GEMINI_CLI_COMPANION_EXTENSION_NAME,
20
+ } from '@google/gemini-cli-core';
21
+ import path from 'node:path';
22
+ import type {
23
+ CommandContext,
24
+ SlashCommand,
25
+ SlashCommandActionReturn,
26
+ } from './types.js';
27
+ import { CommandKind } from './types.js';
28
+ import { SettingScope } from '../../config/settings.js';
29
+
30
+ function getIdeStatusMessage(ideClient: IdeClient): {
31
+ messageType: 'info' | 'error';
32
+ content: string;
33
+ } {
34
+ const connection = ideClient.getConnectionStatus();
35
+ switch (connection.status) {
36
+ case IDEConnectionStatus.Connected:
37
+ return {
38
+ messageType: 'info',
39
+ content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
40
+ };
41
+ case IDEConnectionStatus.Connecting:
42
+ return {
43
+ messageType: 'info',
44
+ content: `🟡 Connecting...`,
45
+ };
46
+ default: {
47
+ let content = `🔴 Disconnected`;
48
+ if (connection?.details) {
49
+ content += `: ${connection.details}`;
50
+ }
51
+ return {
52
+ messageType: 'error',
53
+ content,
54
+ };
55
+ }
56
+ }
57
+ }
58
+
59
+ function formatFileList(openFiles: File[]): string {
60
+ const basenameCounts = new Map<string, number>();
61
+ for (const file of openFiles) {
62
+ const basename = path.basename(file.path);
63
+ basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
64
+ }
65
+
66
+ const fileList = openFiles
67
+ .map((file: File) => {
68
+ const basename = path.basename(file.path);
69
+ const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
70
+ const parentDir = path.basename(path.dirname(file.path));
71
+ const displayName = isDuplicate
72
+ ? `${basename} (/${parentDir})`
73
+ : basename;
74
+
75
+ return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
76
+ })
77
+ .join('\n');
78
+
79
+ const infoMessage = `
80
+ (Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
81
+
82
+ return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
83
+ }
84
+
85
+ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
86
+ messageType: 'info' | 'error';
87
+ content: string;
88
+ }> {
89
+ const connection = ideClient.getConnectionStatus();
90
+ switch (connection.status) {
91
+ case IDEConnectionStatus.Connected: {
92
+ let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
93
+ const context = ideContextStore.get();
94
+ const openFiles = context?.workspaceState?.openFiles;
95
+ if (openFiles && openFiles.length > 0) {
96
+ content += formatFileList(openFiles);
97
+ }
98
+ return {
99
+ messageType: 'info',
100
+ content,
101
+ };
102
+ }
103
+ case IDEConnectionStatus.Connecting:
104
+ return {
105
+ messageType: 'info',
106
+ content: `🟡 Connecting...`,
107
+ };
108
+ default: {
109
+ let content = `🔴 Disconnected`;
110
+ if (connection?.details) {
111
+ content += `: ${connection.details}`;
112
+ }
113
+ return {
114
+ messageType: 'error',
115
+ content,
116
+ };
117
+ }
118
+ }
119
+ }
120
+
121
+ async function setIdeModeAndSyncConnection(
122
+ config: Config,
123
+ value: boolean,
124
+ options: { logToConsole?: boolean } = {},
125
+ ): Promise<void> {
126
+ config.setIdeMode(value);
127
+ const ideClient = await IdeClient.getInstance();
128
+ if (value) {
129
+ await ideClient.connect(options);
130
+ logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
131
+ } else {
132
+ await ideClient.disconnect();
133
+ }
134
+ }
135
+
136
+ export const ideCommand = async (): Promise<SlashCommand> => {
137
+ const ideClient = await IdeClient.getInstance();
138
+ const currentIDE = ideClient.getCurrentIde();
139
+ if (!currentIDE) {
140
+ return {
141
+ name: 'ide',
142
+ description: 'Manage IDE integration',
143
+ kind: CommandKind.BUILT_IN,
144
+ autoExecute: false,
145
+ action: (): SlashCommandActionReturn =>
146
+ ({
147
+ type: 'message',
148
+ messageType: 'error',
149
+ content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
150
+ }) as const,
151
+ };
152
+ }
153
+
154
+ const ideSlashCommand: SlashCommand = {
155
+ name: 'ide',
156
+ description: 'Manage IDE integration',
157
+ kind: CommandKind.BUILT_IN,
158
+ autoExecute: false,
159
+ subCommands: [],
160
+ };
161
+
162
+ const statusCommand: SlashCommand = {
163
+ name: 'status',
164
+ description: 'Check status of IDE integration',
165
+ kind: CommandKind.BUILT_IN,
166
+ autoExecute: true,
167
+ action: async (): Promise<SlashCommandActionReturn> => {
168
+ const { messageType, content } =
169
+ await getIdeStatusMessageWithFiles(ideClient);
170
+ return {
171
+ type: 'message',
172
+ messageType,
173
+ content,
174
+ } as const;
175
+ },
176
+ };
177
+
178
+ const installCommand: SlashCommand = {
179
+ name: 'install',
180
+ description: `Install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
181
+ kind: CommandKind.BUILT_IN,
182
+ autoExecute: true,
183
+ action: async (context) => {
184
+ const installer = getIdeInstaller(currentIDE);
185
+ if (!installer) {
186
+ context.ui.addItem(
187
+ {
188
+ type: 'error',
189
+ text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${GEMINI_CLI_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
190
+ },
191
+ Date.now(),
192
+ );
193
+ return;
194
+ }
195
+
196
+ context.ui.addItem(
197
+ {
198
+ type: 'info',
199
+ text: `Installing IDE companion...`,
200
+ },
201
+ Date.now(),
202
+ );
203
+
204
+ const result = await installer.install();
205
+ context.ui.addItem(
206
+ {
207
+ type: result.success ? 'info' : 'error',
208
+ text: result.message,
209
+ },
210
+ Date.now(),
211
+ );
212
+ if (result.success) {
213
+ context.services.settings.setValue(
214
+ SettingScope.User,
215
+ 'ide.enabled',
216
+ true,
217
+ );
218
+ // Poll for up to 5 seconds for the extension to activate.
219
+ for (let i = 0; i < 10; i++) {
220
+ await setIdeModeAndSyncConnection(
221
+ context.services.agentContext!.config,
222
+ true,
223
+ {
224
+ logToConsole: false,
225
+ },
226
+ );
227
+ if (
228
+ ideClient.getConnectionStatus().status ===
229
+ IDEConnectionStatus.Connected
230
+ ) {
231
+ break;
232
+ }
233
+ await new Promise((resolve) => setTimeout(resolve, 500));
234
+ }
235
+
236
+ const { messageType, content } = getIdeStatusMessage(ideClient);
237
+ if (messageType === 'error') {
238
+ context.ui.addItem(
239
+ {
240
+ type: messageType,
241
+ text: `Failed to automatically enable IDE integration. To fix this, run the CLI in a new terminal window.`,
242
+ },
243
+ Date.now(),
244
+ );
245
+ } else {
246
+ context.ui.addItem(
247
+ {
248
+ type: messageType,
249
+ text: content,
250
+ },
251
+ Date.now(),
252
+ );
253
+ }
254
+ }
255
+ },
256
+ };
257
+
258
+ const enableCommand: SlashCommand = {
259
+ name: 'enable',
260
+ description: 'Enable IDE integration',
261
+ kind: CommandKind.BUILT_IN,
262
+ autoExecute: true,
263
+ action: async (context: CommandContext) => {
264
+ context.services.settings.setValue(
265
+ SettingScope.User,
266
+ 'ide.enabled',
267
+ true,
268
+ );
269
+ await setIdeModeAndSyncConnection(
270
+ context.services.agentContext!.config,
271
+ true,
272
+ );
273
+ const { messageType, content } = getIdeStatusMessage(ideClient);
274
+ context.ui.addItem(
275
+ {
276
+ type: messageType,
277
+ text: content,
278
+ },
279
+ Date.now(),
280
+ );
281
+ },
282
+ };
283
+
284
+ const disableCommand: SlashCommand = {
285
+ name: 'disable',
286
+ description: 'Disable IDE integration',
287
+ kind: CommandKind.BUILT_IN,
288
+ autoExecute: true,
289
+ action: async (context: CommandContext) => {
290
+ context.services.settings.setValue(
291
+ SettingScope.User,
292
+ 'ide.enabled',
293
+ false,
294
+ );
295
+ await setIdeModeAndSyncConnection(
296
+ context.services.agentContext!.config,
297
+ false,
298
+ );
299
+ const { messageType, content } = getIdeStatusMessage(ideClient);
300
+ context.ui.addItem(
301
+ {
302
+ type: messageType,
303
+ text: content,
304
+ },
305
+ Date.now(),
306
+ );
307
+ },
308
+ };
309
+
310
+ const { status } = ideClient.getConnectionStatus();
311
+ const isConnected = status === IDEConnectionStatus.Connected;
312
+
313
+ if (isConnected) {
314
+ ideSlashCommand.subCommands = [statusCommand, disableCommand];
315
+ } else {
316
+ ideSlashCommand.subCommands = [
317
+ enableCommand,
318
+ statusCommand,
319
+ installCommand,
320
+ ];
321
+ }
322
+
323
+ return ideSlashCommand;
324
+ };
packages/cli/src/ui/commands/initCommand.test.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { initCommand } from './initCommand.js';
11
+ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
12
+ import type { CommandContext } from './types.js';
13
+ import type { SubmitPromptActionReturn } from '@google/gemini-cli-core';
14
+
15
+ // Mock the 'fs' module
16
+ vi.mock('fs', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('node:fs')>();
18
+ return {
19
+ ...actual,
20
+ existsSync: vi.fn(),
21
+ writeFileSync: vi.fn(),
22
+ };
23
+ });
24
+
25
+ describe('initCommand', () => {
26
+ let mockContext: CommandContext;
27
+ const targetDir = '/test/dir';
28
+ const geminiMdPath = path.join(targetDir, 'GEMINI.md');
29
+
30
+ beforeEach(() => {
31
+ // Create a fresh mock context for each test
32
+ mockContext = createMockCommandContext({
33
+ services: {
34
+ agentContext: {
35
+ config: {
36
+ getTargetDir: () => targetDir,
37
+ },
38
+ },
39
+ },
40
+ });
41
+ });
42
+
43
+ afterEach(() => {
44
+ // Clear all mocks after each test
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ it('should inform the user if GEMINI.md already exists', async () => {
49
+ // Arrange: Simulate that the file exists
50
+ vi.mocked(fs.existsSync).mockReturnValue(true);
51
+
52
+ // Act: Run the command's action
53
+ const result = await initCommand.action!(mockContext, '');
54
+
55
+ // Assert: Check for the correct informational message
56
+ expect(result).toEqual({
57
+ type: 'message',
58
+ messageType: 'info',
59
+ content:
60
+ 'A GEMINI.md file already exists in this directory. No changes were made.',
61
+ });
62
+ // Assert: Ensure no file was written
63
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
67
+ // Arrange: Simulate that the file does not exist
68
+ vi.mocked(fs.existsSync).mockReturnValue(false);
69
+
70
+ // Act: Run the command's action
71
+ const result = (await initCommand.action!(
72
+ mockContext,
73
+ '',
74
+ )) as SubmitPromptActionReturn;
75
+
76
+ // Assert: Check that writeFileSync was called correctly
77
+ expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
78
+
79
+ // Assert: Check that an informational message was added to the UI
80
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
81
+ {
82
+ type: 'info',
83
+ text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
84
+ },
85
+ expect.any(Number),
86
+ );
87
+
88
+ // Assert: Check that the correct prompt is submitted
89
+ expect(result.type).toBe('submit_prompt');
90
+ expect(result.content).toContain(
91
+ 'You are an AI agent that brings the power of Gemini',
92
+ );
93
+ });
94
+
95
+ it('should return an error if config is not available', async () => {
96
+ // Arrange: Create a context without config
97
+ const noConfigContext = createMockCommandContext();
98
+ if (noConfigContext.services) {
99
+ noConfigContext.services.agentContext = null;
100
+ }
101
+
102
+ // Act: Run the command's action
103
+ const result = await initCommand.action!(noConfigContext, '');
104
+
105
+ // Assert: Check for the correct error message
106
+ expect(result).toEqual({
107
+ type: 'message',
108
+ messageType: 'error',
109
+ content: 'Configuration not available.',
110
+ });
111
+ });
112
+ });
packages/cli/src/ui/commands/initCommand.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import type {
10
+ CommandContext,
11
+ SlashCommand,
12
+ SlashCommandActionReturn,
13
+ } from './types.js';
14
+ import { CommandKind } from './types.js';
15
+ import { performInit } from '@google/gemini-cli-core';
16
+
17
+ export const initCommand: SlashCommand = {
18
+ name: 'init',
19
+ description: 'Analyzes the project and creates a tailored GEMINI.md file',
20
+ kind: CommandKind.BUILT_IN,
21
+ autoExecute: true,
22
+ action: async (
23
+ context: CommandContext,
24
+ _args: string,
25
+ ): Promise<SlashCommandActionReturn> => {
26
+ if (!context.services.agentContext?.config) {
27
+ return {
28
+ type: 'message',
29
+ messageType: 'error',
30
+ content: 'Configuration not available.',
31
+ };
32
+ }
33
+ const targetDir = context.services.agentContext.config.getTargetDir();
34
+ const geminiMdPath = path.join(targetDir, 'GEMINI.md');
35
+
36
+ const result = performInit(fs.existsSync(geminiMdPath));
37
+
38
+ if (result.type === 'submit_prompt') {
39
+ // Create an empty GEMINI.md file
40
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
41
+
42
+ context.ui.addItem(
43
+ {
44
+ type: 'info',
45
+ text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
46
+ },
47
+ Date.now(),
48
+ );
49
+ }
50
+
51
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
52
+ return result as SlashCommandActionReturn;
53
+ },
54
+ };