Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/cli/src/ui/__snapshots__/App.test.tsx.snap +152 -0
- 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
- packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +43 -0
- packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap +19 -0
- packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap +60 -0
- packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap +17 -0
- packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap +17 -0
- packages/cli/src/ui/commands/__snapshots__/commandsCommand.test.ts.snap +12 -0
- packages/cli/src/ui/commands/aboutCommand.test.ts +181 -0
- packages/cli/src/ui/commands/aboutCommand.ts +77 -0
- packages/cli/src/ui/commands/agentsCommand.test.ts +514 -0
- packages/cli/src/ui/commands/agentsCommand.ts +394 -0
- packages/cli/src/ui/commands/authCommand.test.ts +138 -0
- packages/cli/src/ui/commands/authCommand.ts +58 -0
- packages/cli/src/ui/commands/bugCommand.test.ts +344 -0
- packages/cli/src/ui/commands/bugCommand.ts +194 -0
- packages/cli/src/ui/commands/bugMemoryCommand.test.ts +121 -0
- packages/cli/src/ui/commands/bugMemoryCommand.ts +86 -0
- packages/cli/src/ui/commands/chatCommand.test.ts +763 -0
- packages/cli/src/ui/commands/chatCommand.ts +430 -0
- packages/cli/src/ui/commands/clearCommand.test.ts +119 -0
- packages/cli/src/ui/commands/clearCommand.ts +88 -0
- packages/cli/src/ui/commands/commandsCommand.test.ts +157 -0
- packages/cli/src/ui/commands/commandsCommand.ts +144 -0
- packages/cli/src/ui/commands/compressCommand.test.ts +146 -0
- packages/cli/src/ui/commands/compressCommand.ts +86 -0
- packages/cli/src/ui/commands/copyCommand.test.ts +317 -0
- packages/cli/src/ui/commands/copyCommand.ts +70 -0
- packages/cli/src/ui/commands/corgiCommand.test.ts +34 -0
- packages/cli/src/ui/commands/corgiCommand.ts +18 -0
- packages/cli/src/ui/commands/directoryCommand.test.tsx +461 -0
- packages/cli/src/ui/commands/directoryCommand.tsx +299 -0
- packages/cli/src/ui/commands/docsCommand.test.ts +99 -0
- packages/cli/src/ui/commands/docsCommand.ts +43 -0
- packages/cli/src/ui/commands/editorCommand.test.ts +30 -0
- packages/cli/src/ui/commands/editorCommand.ts +22 -0
- packages/cli/src/ui/commands/exportSessionCommand.test.ts +135 -0
- packages/cli/src/ui/commands/exportSessionCommand.ts +98 -0
- packages/cli/src/ui/commands/extensionsCommand.test.ts +1227 -0
- packages/cli/src/ui/commands/extensionsCommand.ts +916 -0
- packages/cli/src/ui/commands/footerCommand.tsx +25 -0
- packages/cli/src/ui/commands/gemmaStatusCommand.ts +41 -0
- packages/cli/src/ui/commands/helpCommand.test.ts +133 -0
- packages/cli/src/ui/commands/helpCommand.ts +46 -0
- packages/cli/src/ui/commands/hooksCommand.test.ts +835 -0
- packages/cli/src/ui/commands/hooksCommand.ts +411 -0
- packages/cli/src/ui/commands/ideCommand.test.ts +274 -0
- packages/cli/src/ui/commands/ideCommand.ts +324 -0
- packages/cli/src/ui/commands/initCommand.test.ts +112 -0
- 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 |
+
};
|