chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
## React & Ink (CLI UI)
|
||||
|
||||
- **Side Effects**: Use reducers for complex state transitions; avoid `setState`
|
||||
triggers in callbacks.
|
||||
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
|
||||
dependencies.
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/ui/key/keyBindings.ts`
|
||||
- Do not implement any logic performing custom string measurement or string
|
||||
truncation. Use Ink layout instead leveraging ResizeObserver as needed. When
|
||||
using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Utilities**: Use `renderWithProviders` and `waitFor` from
|
||||
`packages/cli/src/test-utils/`.
|
||||
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
|
||||
- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for
|
||||
UI components whenever colors or detailed visual layout matter. SVG snapshots
|
||||
capture styling accurately. Make sure to await the `waitUntilReady()` of the
|
||||
render result before asserting. After updating SVG snapshots, always examine
|
||||
the resulting `.svg` files (e.g. by reading their content or visually
|
||||
inspecting them) to ensure the render and colors actually look as expected and
|
||||
don't just contain an error message.
|
||||
- **Mocks**: Use mocks as sparingly as possible.
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
|
||||
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
const DEMO_QUESTIONS: Question[] = [
|
||||
{
|
||||
question: 'What type of project are you building?',
|
||||
header: 'Project Type',
|
||||
options: [
|
||||
{ label: 'Web Application', description: 'React, Next.js, or similar' },
|
||||
{ label: 'CLI Tool', description: 'Command-line interface with Node.js' },
|
||||
{ label: 'Library', description: 'NPM package or shared utility' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which features should be enabled?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Add static typing' },
|
||||
{ label: 'ESLint', description: 'Add linting and formatting' },
|
||||
{ label: 'Unit Tests', description: 'Add Vitest setup' },
|
||||
{ label: 'CI/CD', description: 'Add GitHub Actions' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
{
|
||||
question: 'What is the project name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'my-awesome-project',
|
||||
},
|
||||
{
|
||||
question: 'Initialize git repository?',
|
||||
header: 'Git',
|
||||
type: QuestionType.YESNO,
|
||||
},
|
||||
];
|
||||
|
||||
const Demo = () => {
|
||||
const [result, setResult] = useState<null | { [key: string]: string }>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color="red">
|
||||
Dialog was cancelled. Project initialization aborted.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
>
|
||||
<Text bold color="green">
|
||||
Success! Project Configuration:
|
||||
</Text>
|
||||
{DEMO_QUESTIONS.map((q, i) => (
|
||||
<Box key={i} marginTop={1}>
|
||||
<Text color="gray">{q.header}: </Text>
|
||||
<Text>{result[i] || '(not answered)'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeypressProvider>
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text bold marginBottom={1}>
|
||||
AskUserDialog Demo
|
||||
</Text>
|
||||
<AskUserDialog
|
||||
questions={DEMO_QUESTIONS}
|
||||
onSubmit={setResult}
|
||||
onCancel={() => setCancelled(true)}
|
||||
/>
|
||||
</Box>
|
||||
</KeypressProvider>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Demo />);
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { render, Box, Text, useInput, useStdout } from 'ink';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from '../src/ui/components/shared/ScrollableList.js';
|
||||
import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js';
|
||||
import { MouseProvider } from '../src/ui/contexts/MouseContext.js';
|
||||
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
|
||||
import {
|
||||
enableMouseEvents,
|
||||
disableMouseEvents,
|
||||
} from '../src/ui/utils/mouse.js';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const getLorem = (index: number) =>
|
||||
Array(10)
|
||||
.fill(null)
|
||||
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
|
||||
.join('\n');
|
||||
|
||||
const Demo = () => {
|
||||
const { stdout } = useStdout();
|
||||
const [size, setSize] = useState({
|
||||
columns: stdout.columns,
|
||||
rows: stdout.rows,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
columns: stdout.columns,
|
||||
rows: stdout.rows,
|
||||
});
|
||||
};
|
||||
|
||||
stdout.on('resize', onResize);
|
||||
return () => {
|
||||
stdout.off('resize', onResize);
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
const [items, setItems] = useState<Item[]>(() =>
|
||||
Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const listRef = useRef<ScrollableListRef<Item>>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === 'a' || input === 'A') {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ id: String(prev.length), title: `Item ${prev.length + 1}` },
|
||||
]);
|
||||
}
|
||||
if ((input === 'e' || input === 'E') && !key.ctrl) {
|
||||
setItems((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const lastIndex = prev.length - 1;
|
||||
const lastItem = prev[lastIndex]!;
|
||||
const newItem = { ...lastItem, title: lastItem.title + 'e' };
|
||||
return [...prev.slice(0, lastIndex), newItem];
|
||||
});
|
||||
}
|
||||
if (key.ctrl && input === 'e') {
|
||||
listRef.current?.scrollToEnd();
|
||||
}
|
||||
// Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed.
|
||||
// For alternate buffer, explicit handling is often safer for cleanup.
|
||||
if (key.escape || (key.ctrl && input === 'c')) {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={true}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={size.columns}
|
||||
height={size.rows - 1}
|
||||
padding={1}
|
||||
>
|
||||
<Text>
|
||||
Press 'A' to add an item. Press 'E' to edit
|
||||
last item. Press 'Ctrl+E' to scroll to end. Press
|
||||
'Esc' to exit. Mouse wheel or Shift+Up/Down to scroll.
|
||||
</Text>
|
||||
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<Box flexDirection="column" paddingBottom={2}>
|
||||
<Box
|
||||
sticky
|
||||
flexDirection="column"
|
||||
width={size.columns - 2}
|
||||
opaque
|
||||
stickyChildren={
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={size.columns - 2}
|
||||
opaque
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor="gray"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{getLorem(index)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 14}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
<Text>Count: {items.length}</Text>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Enable mouse reporting before rendering
|
||||
enableMouseEvents();
|
||||
|
||||
// Ensure cleanup happens on exit
|
||||
process.on('exit', () => {
|
||||
disableMouseEvents();
|
||||
});
|
||||
|
||||
// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time
|
||||
process.on('SIGINT', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
render(<Demo />, { alternateBuffer: true });
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
getSpawnConfig,
|
||||
getScriptArgs,
|
||||
} from './src/utils/processUtils.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows and Linux
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
const isPtyResizeError =
|
||||
message === 'Cannot resize a pty that has already exited';
|
||||
const isEbadfError =
|
||||
message.includes('EBADF') ||
|
||||
(error as { code?: string }).code === 'EBADF';
|
||||
const isFromNodePty =
|
||||
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
|
||||
|
||||
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
|
||||
// This error happens with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
process.stderr.write(error.stack + '\n');
|
||||
} else {
|
||||
process.stderr.write(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function getMemoryNodeArgs(): Promise<string[]> {
|
||||
let autoConfigureMemory = true;
|
||||
try {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
// Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir()
|
||||
const baseDir =
|
||||
process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini');
|
||||
const settingsPath = join(baseDir, 'settings.json');
|
||||
const rawSettings = readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(rawSettings);
|
||||
if (settings?.advanced?.autoConfigureMemory === false) {
|
||||
autoConfigureMemory = false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (autoConfigureMemory) {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const currentMaxOldSpaceSizeMb = Math.floor(
|
||||
heapStats.heap_size_limit / 1024 / 1024,
|
||||
);
|
||||
const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
|
||||
|
||||
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
|
||||
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
|
||||
// --- Lightweight Parent Process / Daemon ---
|
||||
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
|
||||
|
||||
const scriptArgs = getScriptArgs();
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
|
||||
|
||||
let latestAdminSettings: unknown = undefined;
|
||||
|
||||
// Prevent the parent process from exiting prematurely on signals.
|
||||
// The child process will receive the same signals and handle its own cleanup.
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||
process.on(sig as NodeJS.Signals, () => {});
|
||||
}
|
||||
|
||||
const runner = () => {
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
if (latestAdminSettings) {
|
||||
child.send({ type: 'admin-settings', settings: latestAdminSettings });
|
||||
}
|
||||
|
||||
child.on('message', (msg: { type?: string; settings?: unknown }) => {
|
||||
if (msg.type === 'admin-settings-update' && msg.settings) {
|
||||
latestAdminSettings = msg.settings;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
child.on('error', (err) => {
|
||||
process.stderr.write(
|
||||
'Error: Failed to start child process: ' + err.message + '\n',
|
||||
);
|
||||
resolve(1);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const exitCode = await runner();
|
||||
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
process.stdin.resume();
|
||||
process.stderr.write(
|
||||
`Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- Heavy Child Process ---
|
||||
// Now we can safely import everything.
|
||||
const { main } = await import('./src/gemini.js');
|
||||
const { FatalError, writeToStderr } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const { runExitCleanup } = await import('./src/utils/cleanup.js');
|
||||
|
||||
main().catch(async (error: unknown) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError: unknown) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
if (!process.env['NO_COLOR']) {
|
||||
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
|
||||
}
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.52.0-nightly.20260707.g27a3da3e8",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"gemini": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build_package.js",
|
||||
"start": "node dist/index.js",
|
||||
"debug": "node --inspect-brk dist/index.js",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"posttest": "npm run build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0-nightly.20260707.g27a3da3e8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"ansi-escapes": "7.3.0",
|
||||
"ansi-regex": "6.2.2",
|
||||
"chalk": "4.1.2",
|
||||
"cli-spinners": "2.9.2",
|
||||
"clipboardy": "5.2.0",
|
||||
"color-convert": "2.0.1",
|
||||
"command-exists": "1.2.9",
|
||||
"comment-json": "4.2.5",
|
||||
"diff": "8.0.3",
|
||||
"dotenv": "17.1.0",
|
||||
"extract-zip": "2.0.1",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
"lowlight": "3.3.0",
|
||||
"mnemonist": "0.40.3",
|
||||
"open": "10.1.2",
|
||||
"prompts": "2.4.2",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"react": "19.2.4",
|
||||
"shell-quote": "1.8.3",
|
||||
"simple-git": "3.28.0",
|
||||
"string-width": "8.1.0",
|
||||
"strip-ansi": "7.1.0",
|
||||
"strip-json-comments": "3.1.1",
|
||||
"tar": "7.5.8",
|
||||
"tinygradient": "1.1.5",
|
||||
"undici": "7.10.0",
|
||||
"ws": "8.16.0",
|
||||
"yargs": "17.7.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/command-exists": "1.2.3",
|
||||
"@types/hast": "3.0.4",
|
||||
"@types/node": "20.11.24",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/shell-quote": "1.7.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"@types/yargs": "17.0.32",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
|
||||
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
|
||||
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
|
||||
"Use mock tool
|
||||
Use mock tool again
|
||||
Finished.
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,35 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
|
||||
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
|
||||
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
|
||||
"Use mock tool
|
||||
Use mock tool again
|
||||
Finished.
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
# Agent Client Protocol (ACP) Implementation
|
||||
|
||||
This directory contains the implementation of the Agent Client Protocol (ACP)
|
||||
for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
|
||||
communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Following Phase 1 of the modularization refactor, the ACP client is organized
|
||||
into the following specialized modules, all sharing the `acp` prefix for
|
||||
consistency:
|
||||
|
||||
- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
|
||||
up the Web streams for standard input/output and creates the
|
||||
`AgentSideConnection` using line-delimited JSON (ndjson).
|
||||
- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
|
||||
class. This is the main entry point for incoming JSON-RPC messages. It
|
||||
implements the protocol methods and delegates session-specific work to the
|
||||
manager and individual sessions.
|
||||
- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
|
||||
state. It handles session creation (`newSession`), loading (`loadSession`),
|
||||
and configuration, isolating session state from the RPC routing.
|
||||
- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
|
||||
It handles prompt execution, `@path` file resolution, tool execution, command
|
||||
interception, and streaming updates back to the client.
|
||||
- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
|
||||
mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
|
||||
across the modules.
|
||||
- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
|
||||
ACP-compliant error codes.
|
||||
- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
|
||||
execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
|
||||
- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
|
||||
the file system restricted by the workspace boundaries and permissions.
|
||||
|
||||
## Development Instructions
|
||||
|
||||
### Running Tests
|
||||
|
||||
Tests are co-located with the source files:
|
||||
|
||||
- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
|
||||
handler delegation.
|
||||
- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
|
||||
- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
|
||||
resolution.
|
||||
- `acpResume.test.ts`: Integration tests for loading/resuming sessions.
|
||||
|
||||
To run specific tests, use Vitest with the workspace filter:
|
||||
|
||||
```bash
|
||||
# General pattern
|
||||
npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
|
||||
|
||||
# Example
|
||||
npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
|
||||
```
|
||||
|
||||
Note: You may need to ensure your environment has Node available. If running in
|
||||
a restricted environment, try sourcing NVM first:
|
||||
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
|
||||
and register it in the `AgentSideConnection` setup if necessary.
|
||||
- **Session State**: If a feature requires storing state across turns within a
|
||||
session, add it to the `Session` class in `acpSession.ts`.
|
||||
- **Protocol Helpers**: Add any new mapping or serialization logic to
|
||||
`acpUtils.ts`.
|
||||
|
||||
### Coding Conventions
|
||||
|
||||
- **Imports**: Use specific imports and do not import across package boundaries
|
||||
using relative paths.
|
||||
- **License Headers**: All new files must include the Apache-2.0 license header.
|
||||
- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
|
||||
untrusted input from the protocol.
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
it('parses commands correctly', () => {
|
||||
const handler = new CommandHandler();
|
||||
// @ts-expect-error - testing private method
|
||||
const parse = (query: string) => handler.parseSlashCommand(query);
|
||||
|
||||
const memShow = parse('/memory show');
|
||||
expect(memShow.commandToExecute?.name).toBe('memory show');
|
||||
expect(memShow.args).toBe('');
|
||||
|
||||
const memList = parse('/memory list');
|
||||
expect(memList.commandToExecute?.name).toBe('memory list');
|
||||
|
||||
const extList = parse('/extensions list');
|
||||
expect(extList.commandToExecute?.name).toBe('extensions list');
|
||||
|
||||
const init = parse('/init');
|
||||
expect(init.commandToExecute?.name).toBe('init');
|
||||
|
||||
const about = parse('/about');
|
||||
expect(about.commandToExecute?.name).toBe('about');
|
||||
|
||||
const help = parse('/help');
|
||||
expect(help.commandToExecute?.name).toBe('help');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Command, CommandContext } from './commands/types.js';
|
||||
import { CommandRegistry } from './commands/commandRegistry.js';
|
||||
import { MemoryCommand } from './commands/memory.js';
|
||||
import { ExtensionsCommand } from './commands/extensions.js';
|
||||
import { InitCommand } from './commands/init.js';
|
||||
import { RestoreCommand } from './commands/restore.js';
|
||||
import { AboutCommand } from './commands/about.js';
|
||||
import { HelpCommand } from './commands/help.js';
|
||||
|
||||
export class CommandHandler {
|
||||
private registry: CommandRegistry;
|
||||
|
||||
constructor() {
|
||||
this.registry = CommandHandler.createRegistry();
|
||||
}
|
||||
|
||||
private static createRegistry(): CommandRegistry {
|
||||
const registry = new CommandRegistry();
|
||||
registry.register(new MemoryCommand());
|
||||
registry.register(new ExtensionsCommand());
|
||||
registry.register(new InitCommand());
|
||||
registry.register(new RestoreCommand());
|
||||
registry.register(new AboutCommand());
|
||||
registry.register(new HelpCommand(registry));
|
||||
return registry;
|
||||
}
|
||||
|
||||
getAvailableCommands(): Array<{ name: string; description: string }> {
|
||||
return this.registry.getAllCommands().map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and executes a command string if it matches a registered command.
|
||||
* Returns true if a command was handled, false otherwise.
|
||||
*/
|
||||
async handleCommand(
|
||||
commandText: string,
|
||||
context: CommandContext,
|
||||
): Promise<boolean> {
|
||||
const { commandToExecute, args } = this.parseSlashCommand(commandText);
|
||||
|
||||
if (commandToExecute) {
|
||||
await this.runCommand(commandToExecute, args, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
commandToExecute: Command,
|
||||
args: string,
|
||||
context: CommandContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await commandToExecute.execute(
|
||||
context,
|
||||
args ? args.split(/\s+/) : [],
|
||||
);
|
||||
|
||||
let messageContent = '';
|
||||
if (typeof result.data === 'string') {
|
||||
messageContent = result.data;
|
||||
} else if (
|
||||
typeof result.data === 'object' &&
|
||||
result.data !== null &&
|
||||
'content' in result.data
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
messageContent = (result.data as Record<string, any>)[
|
||||
'content'
|
||||
] as string;
|
||||
} else {
|
||||
messageContent = JSON.stringify(result.data, null, 2);
|
||||
}
|
||||
|
||||
await context.sendMessage(messageContent);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await context.sendMessage(`Error: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw slash command string into its matching headless command and arguments.
|
||||
* Mirrors `packages/cli/src/utils/commands.ts` logic.
|
||||
*/
|
||||
private parseSlashCommand(query: string): {
|
||||
commandToExecute: Command | undefined;
|
||||
args: string;
|
||||
} {
|
||||
const trimmed = query.trim();
|
||||
const parts = trimmed.substring(1).trim().split(/\s+/);
|
||||
const commandPath = parts.filter((p) => p);
|
||||
|
||||
let currentCommands = this.registry.getAllCommands();
|
||||
let commandToExecute: Command | undefined;
|
||||
let pathIndex = 0;
|
||||
|
||||
for (const part of commandPath) {
|
||||
const foundCommand = currentCommands.find((cmd) => {
|
||||
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
|
||||
return (
|
||||
cmd.name === part ||
|
||||
cmd.name === expectedName ||
|
||||
cmd.aliases?.includes(part) ||
|
||||
cmd.aliases?.includes(expectedName)
|
||||
);
|
||||
});
|
||||
|
||||
if (foundCommand) {
|
||||
commandToExecute = foundCommand;
|
||||
pathIndex++;
|
||||
if (foundCommand.subCommands) {
|
||||
currentCommands = foundCommand.subCommands;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const args = parts.slice(pathIndex).join(' ');
|
||||
|
||||
return { commandToExecute, args };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
|
||||
describe('getAcpErrorMessage', () => {
|
||||
it('should return plain error message', () => {
|
||||
expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error');
|
||||
});
|
||||
|
||||
it('should parse simple JSON error response', () => {
|
||||
const json = JSON.stringify({ error: { message: 'json error' } });
|
||||
expect(getAcpErrorMessage(new Error(json))).toBe('json error');
|
||||
});
|
||||
|
||||
it('should parse double-encoded JSON error response', () => {
|
||||
const innerJson = JSON.stringify({ error: { message: 'nested error' } });
|
||||
const outerJson = JSON.stringify({ error: { message: innerJson } });
|
||||
expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error');
|
||||
});
|
||||
|
||||
it('should parse array-style JSON error response', () => {
|
||||
const json = JSON.stringify([{ error: { message: 'array error' } }]);
|
||||
expect(getAcpErrorMessage(new Error(json))).toBe('array error');
|
||||
});
|
||||
|
||||
it('should parse JSON with top-level message field', () => {
|
||||
const json = JSON.stringify({ message: 'top-level message' });
|
||||
expect(getAcpErrorMessage(new Error(json))).toBe('top-level message');
|
||||
});
|
||||
|
||||
it('should handle JSON with trailing newline', () => {
|
||||
const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n';
|
||||
expect(getAcpErrorMessage(new Error(json))).toBe('newline error');
|
||||
});
|
||||
|
||||
it('should return original message if JSON parsing fails', () => {
|
||||
const invalidJson = '{ not-json }';
|
||||
expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Extracts a human-readable error message specifically for ACP (IDE) clients.
|
||||
* This function recursively parses JSON error blobs that are common in
|
||||
* Google API responses but ugly to display in an IDE's UI.
|
||||
*/
|
||||
export function getAcpErrorMessage(error: unknown): string {
|
||||
const coreMessage = getCoreErrorMessage(error);
|
||||
return extractRecursiveMessage(coreMessage);
|
||||
}
|
||||
|
||||
function extractRecursiveMessage(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Attempt to parse JSON error responses (common in Google API errors)
|
||||
if (
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsed = JSON.parse(trimmed);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const next =
|
||||
parsed?.error?.message ||
|
||||
parsed?.[0]?.error?.message ||
|
||||
parsed?.message;
|
||||
|
||||
if (next && typeof next === 'string' && next !== input) {
|
||||
return extractRecursiveMessage(next);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to original string if parsing fails
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
let mockConnection: Mocked<AgentSideConnection>;
|
||||
let mockFallback: Mocked<FileSystemService>;
|
||||
let service: AcpFileSystemService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConnection = {
|
||||
requestPermission: vi.fn(),
|
||||
sessionUpdate: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as Mocked<AgentSideConnection>;
|
||||
mockFallback = {
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
};
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('readTextFile', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
setup: () => {
|
||||
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockConnection.readTextFile).toHaveBeenCalledWith({
|
||||
path: '/path/to/file',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
expect(mockFallback.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/path/to/file',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should use $desc',
|
||||
async ({ capability, path, root, setup, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: capability, writeTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
setup();
|
||||
|
||||
const result = await service.readTextFile(path);
|
||||
|
||||
expect(result).toBe('content');
|
||||
verify();
|
||||
},
|
||||
);
|
||||
|
||||
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.readTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for document'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.readTextFile('/path/to/missing'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for document',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeTextFile', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
verify: () => {
|
||||
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
|
||||
path: '/path/to/file',
|
||||
content: 'content',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
expect(mockFallback.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/path/to/file',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, path, root, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ writeTextFile: capability, readTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
|
||||
await service.writeTextFile(path, 'content');
|
||||
|
||||
verify();
|
||||
});
|
||||
|
||||
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.writeTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for directory'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.writeTextFile('/path/to/missing', 'content'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
private readonly geminiDir = path.join(os.homedir(), '.gemini');
|
||||
|
||||
constructor(
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapabilities,
|
||||
private readonly fallback: FileSystemService,
|
||||
private readonly root: string,
|
||||
) {}
|
||||
|
||||
private shouldUseFallback(filePath: string): boolean {
|
||||
// Files inside the global CLI directory must always use the native file system,
|
||||
// even if the user runs the CLI directly from their home directory (which
|
||||
// would make the IDE's project root overlap with the global directory).
|
||||
return (
|
||||
!isWithinRoot(filePath, this.root) ||
|
||||
isWithinRoot(filePath, this.geminiDir)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeFileSystemError(err: unknown): never {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
errorMessage.includes('Resource not found') ||
|
||||
errorMessage.includes('ENOENT') ||
|
||||
errorMessage.includes('does not exist') ||
|
||||
errorMessage.includes('No such file')
|
||||
) {
|
||||
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
|
||||
newErr.code = 'ENOENT';
|
||||
throw newErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async readTextFile(filePath: string): Promise<string> {
|
||||
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
|
||||
const content: unknown = response.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('content must be a string'); // replace with other response type formats when modified in the future
|
||||
}
|
||||
return content;
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(filePath: string, content: string): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.writeTextFile(filePath, content);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
type Config,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { waitFor } from '../test-utils/async.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
SessionSelector: vi.fn(),
|
||||
convertSessionToHistoryFormats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
CoreToolCallStatus: {
|
||||
Validating: 'validating',
|
||||
Scheduled: 'scheduled',
|
||||
Error: 'error',
|
||||
Success: 'success',
|
||||
Executing: 'executing',
|
||||
Cancelled: 'cancelled',
|
||||
AwaitingApproval: 'awaiting_approval',
|
||||
},
|
||||
LlmRole: {
|
||||
MAIN: 'main',
|
||||
SUBAGENT: 'subagent',
|
||||
UTILITY_TOOL: 'utility_tool',
|
||||
USER: 'user',
|
||||
MODEL: 'model',
|
||||
SYSTEM: 'system',
|
||||
TOOL: 'tool',
|
||||
},
|
||||
convertSessionToClientHistory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent Session Resume', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
resumeChat: vi.fn().mockResolvedValue(undefined),
|
||||
getChat: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
toolRegistry: {
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
},
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should advertise loadSession capability', async () => {
|
||||
const response = await agent.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should load a session, resume chat, and stream all message types', async () => {
|
||||
const sessionId = 'existing-session-id';
|
||||
const sessionData = {
|
||||
sessionId,
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Hello' }] },
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Hi there' }],
|
||||
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
displayName: 'Test Tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
resultDisplay: 'Tool output',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Trying a write' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'write_file',
|
||||
displayName: 'Write File',
|
||||
status: CoreToolCallStatus.Error,
|
||||
resultDisplay: 'Permission denied',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(SessionSelector as unknown as Mock).mockImplementation(() => ({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionData,
|
||||
sessionPath: '/path/to/session.json',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockClientHistory = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
];
|
||||
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
||||
uiHistory: [],
|
||||
});
|
||||
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
|
||||
mockClientHistory,
|
||||
);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
modes: {
|
||||
availableModes: [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
},
|
||||
],
|
||||
currentModeId: ApprovalMode.DEFAULT,
|
||||
},
|
||||
models: {
|
||||
availableModels: expect.any(Array) as unknown,
|
||||
currentModelId: 'gemini-pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify resumeChat received the correct arguments
|
||||
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
|
||||
mockClientHistory,
|
||||
expect.objectContaining({
|
||||
conversation: sessionData,
|
||||
filePath: '/path/to/session.json',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hello' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent thought
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: expect.objectContaining({
|
||||
text: '**Thinking**\nabout greeting',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hi there' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Successful tool call → 'completed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-1',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
kind: 'read',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Tool output' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Failed tool call → 'failed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-2',
|
||||
status: 'failed',
|
||||
title: 'Write File',
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings, SettingScope } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent - RPC Dispatcher', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
getDirectories: vi.fn().mockReturnValue(['/tmp']),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should initialize correctly', async () => {
|
||||
const response = await agent.initialize({
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
protocolVersion: 1,
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(4);
|
||||
const gatewayAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.GATEWAY,
|
||||
);
|
||||
expect(gatewayAuth?._meta).toEqual({
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
});
|
||||
const geminiAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiAuth?._meta).toEqual({
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should authenticate correctly', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with api-key in _meta', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.USE_GEMINI,
|
||||
_meta: {
|
||||
'api-key': 'test-api-key',
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
'test-api-key',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with gateway method', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.GATEWAY,
|
||||
undefined,
|
||||
'https://example.com',
|
||||
{ Authorization: 'Bearer token' },
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw acp.RequestError when gateway payload is malformed', async () => {
|
||||
await expect(
|
||||
agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 123,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest),
|
||||
).rejects.toThrow(/Malformed gateway payload/);
|
||||
});
|
||||
|
||||
it('should cancel a session', async () => {
|
||||
const mockSession = {
|
||||
cancelPendingPrompt: vi.fn(),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
await agent.cancel({ sessionId: 'test-session-id' });
|
||||
|
||||
expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when cancelling non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
|
||||
'Session not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate prompt to session', async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [],
|
||||
});
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should delegate setMode to session', async () => {
|
||||
const mockSession = {
|
||||
setMode: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.setSessionMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'plan',
|
||||
});
|
||||
|
||||
expect(mockSession.setMode).toHaveBeenCalledWith('plan');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting mode on non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode({
|
||||
sessionId: 'unknown',
|
||||
modeId: 'plan',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
|
||||
it('should delegate setModel to session (unstable)', async () => {
|
||||
const mockSession = {
|
||||
setModel: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.unstable_setSessionModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
});
|
||||
|
||||
expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting model on non-existent session (unstable)', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.unstable_setSessionModel({
|
||||
sessionId: 'unknown',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentLoopContext,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getVersion,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import { SettingScope, type LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
|
||||
import { hasMeta } from './acpUtils.js';
|
||||
|
||||
export class GeminiAgent {
|
||||
private apiKey: string | undefined;
|
||||
private baseUrl: string | undefined;
|
||||
private customHeaders: Record<string, string> | undefined;
|
||||
private sessionManager: AcpSessionManager;
|
||||
|
||||
constructor(
|
||||
private context: AgentLoopContext,
|
||||
private settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
connection: acp.AgentSideConnection,
|
||||
) {
|
||||
this.sessionManager = new AcpSessionManager(settings, argv, connection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.sessionManager.dispose();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
if (args.clientCapabilities) {
|
||||
this.sessionManager.setClientCapabilities(args.clientCapabilities);
|
||||
}
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
name: 'Log in with Google',
|
||||
description: 'Log in with your Google account',
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_GEMINI,
|
||||
name: 'Gemini API key',
|
||||
description: 'Use an API key with Gemini Developer API',
|
||||
_meta: {
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_VERTEX_AI,
|
||||
name: 'Vertex AI',
|
||||
description: 'Use an API key with Vertex AI GenAI API',
|
||||
},
|
||||
{
|
||||
id: AuthType.GATEWAY,
|
||||
name: 'AI API Gateway',
|
||||
description: 'Use a custom AI API Gateway',
|
||||
_meta: {
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await this.context.config.initialize();
|
||||
const version = await getVersion();
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentInfo: {
|
||||
name: 'gemini-cli',
|
||||
title: 'Gemini CLI',
|
||||
version,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
|
||||
const { methodId } = req;
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
|
||||
// Only clear credentials when switching to a different auth method
|
||||
if (selectedAuthType && selectedAuthType !== method) {
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
// Check for api-key in _meta
|
||||
const meta = hasMeta(req) ? req._meta : undefined;
|
||||
const apiKey =
|
||||
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
|
||||
|
||||
// Refresh auth with the requested method
|
||||
// This will reuse existing credentials if they're valid,
|
||||
// or perform new authentication if needed
|
||||
try {
|
||||
if (apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
// Extract gateway details if present
|
||||
const gatewaySchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
let baseUrl: string | undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
|
||||
if (meta?.['gateway']) {
|
||||
const result = gatewaySchema.safeParse(meta['gateway']);
|
||||
if (result.success) {
|
||||
baseUrl = result.data.baseUrl;
|
||||
headers = result.data.headers;
|
||||
} else {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Malformed gateway payload: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.customHeaders = headers;
|
||||
|
||||
await this.context.config.refreshAuth(
|
||||
method,
|
||||
apiKey ?? this.apiKey,
|
||||
baseUrl,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
|
||||
}
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthDetails(): AuthDetails {
|
||||
return {
|
||||
apiKey: this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
customHeaders: this.customHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(
|
||||
params: acp.NewSessionRequest,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
return this.sessionManager.newSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
return this.sessionManager.loadSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: acp.SetSessionModeRequest,
|
||||
): Promise<acp.SetSessionModeResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: acp.SetSessionModelRequest,
|
||||
): Promise<acp.SetSessionModelResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params.modelId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const startAutoMemoryIfEnabledMock = vi.fn();
|
||||
vi.mock('../utils/autoMemory.js', () => ({
|
||||
startAutoMemoryIfEnabled: (config: Config) =>
|
||||
startAutoMemoryIfEnabledMock(config),
|
||||
}));
|
||||
|
||||
describe('AcpSessionManager', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let manager: AcpSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
getDirectories: vi.fn().mockReturnValue(['/tmp']),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: () => 'test-session-id',
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create a new session', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.sessionId).toBe('test-session-id');
|
||||
expect(loadCliConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.initialize).toHaveBeenCalled();
|
||||
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
|
||||
|
||||
// Verify deferred call (sendAvailableCommands)
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include preview models when user has access', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: GEMINI_MODEL_ALIAS_AUTO,
|
||||
name: expect.stringContaining('Auto'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT include retired preview models (none) in available models', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const modelIds =
|
||||
response.models?.availableModels?.map((m) => m.modelId) ?? [];
|
||||
expect(modelIds).not.toContain('none');
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail session creation if Gemini API key is missing', async () => {
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.USE_GEMINI } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Gemini API key is missing or not configured.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new session with mcp servers', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const mcpServers = [
|
||||
{
|
||||
name: 'test-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: [{ name: 'KEY', value: 'VALUE' }],
|
||||
},
|
||||
];
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(loadCliConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mcpServers: expect.objectContaining({
|
||||
'test-server': expect.objectContaining({
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { KEY: 'VALUE' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
'test-session-id',
|
||||
mockArgv,
|
||||
{ cwd: '/tmp' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication failure gracefully', async () => {
|
||||
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Auth failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize file system service if client supports it', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
manager.setClientCapabilities({
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mockConfig.setFileSystemService).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start auto memory for new ACP sessions', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
AuthType,
|
||||
MCPServerConfig,
|
||||
debugLogger,
|
||||
startupProfiler,
|
||||
convertSessionToClientHistory,
|
||||
createPolicyUpdater,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
import { Session } from './acpSession.js';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
|
||||
export interface AuthDetails {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class AcpSessionManager {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private connection: acp.AgentSideConnection,
|
||||
) {}
|
||||
|
||||
setClientCapabilities(capabilities: acp.ClientCapabilities) {
|
||||
this.clientCapabilities = capabilities;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Session | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
session.dispose();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
async newSession(
|
||||
{ cwd, mcpServers }: acp.NewSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
const config = await this.newSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const authType =
|
||||
loadedSettings.merged.security.auth.selectedType ||
|
||||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
|
||||
? AuthType.GATEWAY
|
||||
: AuthType.USE_GEMINI);
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
authType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
|
||||
// Extra validation for Gemini API key
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI &&
|
||||
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
|
||||
) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = 'Gemini API key is missing or not configured.';
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = getAcpErrorMessage(e);
|
||||
debugLogger.error(
|
||||
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new acp.RequestError(
|
||||
-32000,
|
||||
authErrorMessage || 'Authentication required.',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
authDetails,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config.storage);
|
||||
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const existingSession = this.sessions.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.dispose();
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
authDetails: AuthDetails,
|
||||
): Promise<Config> {
|
||||
const selectedAuthType =
|
||||
this.settings.merged.security.auth.selectedType ||
|
||||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
|
||||
? AuthType.GATEWAY
|
||||
: undefined);
|
||||
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
selectedAuthType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const currentSettings = loadedSettings || this.settings;
|
||||
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
if (
|
||||
'type' in server &&
|
||||
(server.type === 'sse' || server.type === 'http')
|
||||
) {
|
||||
// HTTP or SSE MCP server
|
||||
const headers = Object.fromEntries(
|
||||
server.headers.map(({ name, value }) => [name, value]),
|
||||
);
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
server.type === 'sse' ? server.url : undefined, // url (sse)
|
||||
server.type === 'http' ? server.url : undefined, // httpUrl
|
||||
headers,
|
||||
);
|
||||
} else if ('command' in server) {
|
||||
// Stdio MCP server
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of server.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
server.command,
|
||||
server.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...currentSettings.merged,
|
||||
mcpServers: mergedMcpServers,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
|
||||
export async function runAcpClient(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(stdout, stdin);
|
||||
const connection = new acp.AgentSideConnection(
|
||||
(connection) => new GeminiAgent(config, settings, argv, connection),
|
||||
stream,
|
||||
);
|
||||
|
||||
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
|
||||
// We must explicitly await the connection close to flush telemetry.
|
||||
// Use finally() to ensure cleanup runs even on stream errors.
|
||||
await connection.closed.finally(runExitCleanup);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
Kind,
|
||||
ApprovalMode,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
export function hasMeta(
|
||||
obj: unknown,
|
||||
): obj is { _meta?: Record<string, unknown> } {
|
||||
return typeof obj === 'object' && obj !== null && '_meta' in obj;
|
||||
}
|
||||
|
||||
export const RequestPermissionResponseSchema = z.object({
|
||||
outcome: z.discriminatedUnion('outcome', [
|
||||
z.object({ outcome: z.literal('cancelled') }),
|
||||
z.object({
|
||||
outcome: z.literal('selected'),
|
||||
optionId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export function toToolCallContent(
|
||||
toolResult: ToolResult,
|
||||
): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
}
|
||||
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else {
|
||||
if ('fileName' in toolResult.returnDisplay) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path:
|
||||
toolResult.returnDisplay.filePath ??
|
||||
toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
_meta: {
|
||||
kind: !toolResult.returnDisplay.originalContent
|
||||
? 'add'
|
||||
: toolResult.returnDisplay.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const basicPermissionOptions = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
|
||||
if (!disableAlwaysAllow) {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
// askuser and exit_plan_mode don't need "always allow" options
|
||||
break;
|
||||
default:
|
||||
// No "always allow" options for other types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
options.push(...basicPermissionOptions);
|
||||
|
||||
// Exhaustive check
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
case 'exec':
|
||||
case 'mcp':
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
switch (kind) {
|
||||
case Kind.Read:
|
||||
case Kind.Edit:
|
||||
case Kind.Execute:
|
||||
case Kind.Search:
|
||||
case Kind.Delete:
|
||||
case Kind.Move:
|
||||
case Kind.Think:
|
||||
case Kind.Fetch:
|
||||
case Kind.SwitchMode:
|
||||
case Kind.Other:
|
||||
return kind as acp.ToolKind;
|
||||
case Kind.Agent:
|
||||
return 'think';
|
||||
case Kind.Plan:
|
||||
case Kind.Communicate:
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
const modes: acp.SessionMode[] = [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
];
|
||||
|
||||
if (isPlanEnabled) {
|
||||
modes.push({
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
export function buildAvailableModels(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
} {
|
||||
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_5Flash,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useGemini3_5Flash,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const manualOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
options: Array<{ value: string; title: string; description?: string }>,
|
||||
) =>
|
||||
options.map((o) => ({
|
||||
modelId: o.value,
|
||||
name: o.title,
|
||||
description: o.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels: [
|
||||
...scaleOptions(mainOptions),
|
||||
...scaleOptions(manualOptions),
|
||||
],
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
IdeClient,
|
||||
UserAccountManager,
|
||||
getVersion,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import process from 'node:process';
|
||||
|
||||
export class AboutCommand implements Command {
|
||||
readonly name = 'about';
|
||||
readonly description = 'Show version and environment info';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'];
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.agentContext.config.getModel() || 'Unknown';
|
||||
const cliVersion = await getVersion();
|
||||
const selectedAuthType =
|
||||
context.settings.merged?.security?.auth?.selectedType ?? '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
|
||||
const userAccountManager = new UserAccountManager();
|
||||
const cachedAccount = userAccountManager.getCachedGoogleAccount();
|
||||
const userEmail = cachedAccount ?? 'Unknown';
|
||||
|
||||
const tier = context.agentContext.config.getUserTierName() || 'Unknown';
|
||||
|
||||
const info = [
|
||||
`- Version: ${cliVersion}`,
|
||||
`- OS: ${osVersion}`,
|
||||
`- Sandbox: ${sandboxEnv}`,
|
||||
`- Model: ${modelVersion}`,
|
||||
`- Auth Type: ${selectedAuthType}`,
|
||||
`- GCP Project: ${gcpProject}`,
|
||||
`- IDE Client: ${ideClient}`,
|
||||
`- User Email: ${userEmail}`,
|
||||
`- Tier: ${tier}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Gemini CLI Info:\n${info}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.agentContext.config.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Command } from './types.js';
|
||||
|
||||
export class CommandRegistry {
|
||||
private readonly commands = new Map<string, Command>();
|
||||
|
||||
register(command: Command) {
|
||||
if (this.commands.has(command.name)) {
|
||||
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.commands.set(command.name, command);
|
||||
|
||||
for (const subCommand of command.subCommands ?? []) {
|
||||
this.register(subCommand);
|
||||
}
|
||||
}
|
||||
|
||||
get(commandName: string): Command | undefined {
|
||||
return this.commands.get(commandName);
|
||||
}
|
||||
|
||||
getAllCommands(): Command[] {
|
||||
return [...this.commands.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { CommandContext } from './types.js';
|
||||
import {
|
||||
DisableExtensionCommand,
|
||||
UninstallExtensionCommand,
|
||||
} from './extensions.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
const mockGetErrorMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: mockGetErrorMessage,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => {
|
||||
class MockExtensionManager {
|
||||
disableExtension = vi.fn(async () => undefined);
|
||||
uninstallExtension = vi.fn(async () => undefined);
|
||||
getExtensions = vi.fn(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
ExtensionManager: MockExtensionManager,
|
||||
inferInstallMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type TestExtensionManager = InstanceType<typeof ExtensionManager> & {
|
||||
disableExtension: ReturnType<typeof vi.fn>;
|
||||
uninstallExtension: ReturnType<typeof vi.fn>;
|
||||
getExtensions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createExtensionManager(): TestExtensionManager {
|
||||
return new ExtensionManager({} as never) as TestExtensionManager;
|
||||
}
|
||||
|
||||
function createContext(extensionLoader: unknown): CommandContext {
|
||||
return {
|
||||
agentContext: {
|
||||
config: {
|
||||
getExtensionLoader: vi.fn().mockReturnValue(extensionLoader),
|
||||
},
|
||||
} as unknown as CommandContext['agentContext'],
|
||||
settings: {} as CommandContext['settings'],
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ACP extensions error paths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetErrorMessage.mockImplementation((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when disabling fails', async () => {
|
||||
const command = new DisableExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.disableExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['missing-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions disable',
|
||||
data: 'Failed to disable "missing-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when uninstalling a non-existent extension', async () => {
|
||||
const command = new UninstallExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.uninstallExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['non-existent-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions uninstall',
|
||||
data: 'Failed to uninstall extension "non-existent-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
listExtensions,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class ExtensionsCommand implements Command {
|
||||
readonly name = 'extensions';
|
||||
readonly description = 'Manage extensions.';
|
||||
readonly subCommands = [
|
||||
new ListExtensionsCommand(),
|
||||
new ExploreExtensionsCommand(),
|
||||
new EnableExtensionCommand(),
|
||||
new DisableExtensionCommand(),
|
||||
new InstallExtensionCommand(),
|
||||
new LinkExtensionCommand(),
|
||||
new UninstallExtensionCommand(),
|
||||
new RestartExtensionCommand(),
|
||||
new UpdateExtensionCommand(),
|
||||
];
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ListExtensionsCommand().execute(context, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListExtensionsCommand implements Command {
|
||||
readonly name = 'extensions list';
|
||||
readonly description = 'Lists all installed extensions.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensions = listExtensions(context.agentContext.config);
|
||||
const data = extensions.length ? extensions : 'No extensions installed.';
|
||||
|
||||
return { name: this.name, data };
|
||||
}
|
||||
}
|
||||
|
||||
export class ExploreExtensionsCommand implements Command {
|
||||
readonly name = 'extensions explore';
|
||||
readonly description = 'Explore available extensions.';
|
||||
|
||||
async execute(
|
||||
_context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
return {
|
||||
name: this.name,
|
||||
data: `View or install available extensions at ${extensionsUrl}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getEnableDisableContext(
|
||||
config: Config,
|
||||
args: string[],
|
||||
invocationName: string,
|
||||
) {
|
||||
const extensionManager = config.getExtensionLoader();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
return {
|
||||
error: `Cannot ${invocationName} extensions in this environment.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
|
||||
};
|
||||
}
|
||||
|
||||
let scope = SettingScope.User;
|
||||
if (args.includes('--scope=workspace') || args.includes('workspace')) {
|
||||
scope = SettingScope.Workspace;
|
||||
} else if (args.includes('--scope=session') || args.includes('session')) {
|
||||
scope = SettingScope.Session;
|
||||
}
|
||||
|
||||
const name = args.filter(
|
||||
(a) =>
|
||||
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
|
||||
)[0];
|
||||
|
||||
let names: string[] = [];
|
||||
if (name === '--all') {
|
||||
let extensions = extensionManager.getExtensions();
|
||||
if (invocationName === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
}
|
||||
if (invocationName === 'disable') {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
names = extensions.map((ext) => ext.name);
|
||||
} else if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
return { error: 'No extension name provided.' };
|
||||
}
|
||||
|
||||
return { extensionManager, names, scope };
|
||||
}
|
||||
|
||||
export class EnableExtensionCommand implements Command {
|
||||
readonly name = 'extensions enable';
|
||||
readonly description = 'Enable an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const enableContext = getEnableDisableContext(
|
||||
context.agentContext.config,
|
||||
args,
|
||||
'enable',
|
||||
);
|
||||
if ('error' in enableContext) {
|
||||
return { name: this.name, data: enableContext.error };
|
||||
}
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
const output: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
try {
|
||||
await extensionManager.enableExtension(name, scope);
|
||||
output.push(`Extension "${name}" enabled for scope "${scope}".`);
|
||||
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpClientManager =
|
||||
context.agentContext.config.getMcpClientManager();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers),
|
||||
);
|
||||
|
||||
if (mcpClientManager && enabledServers.length > 0) {
|
||||
const restartPromises = enabledServers.map((serverName) =>
|
||||
mcpClientManager.restartServer(serverName).catch((error) => {
|
||||
output.push(
|
||||
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
await Promise.all(restartPromises);
|
||||
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') || 'No action taken.' };
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableExtensionCommand implements Command {
|
||||
readonly name = 'extensions disable';
|
||||
readonly description = 'Disable an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const enableContext = getEnableDisableContext(
|
||||
context.agentContext.config,
|
||||
args,
|
||||
'disable',
|
||||
);
|
||||
if ('error' in enableContext) {
|
||||
return { name: this.name, data: enableContext.error };
|
||||
}
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
const output: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
try {
|
||||
await extensionManager.disableExtension(name, scope);
|
||||
output.push(`Extension "${name}" disabled for scope "${scope}".`);
|
||||
} catch (e) {
|
||||
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') || 'No action taken.' };
|
||||
}
|
||||
}
|
||||
|
||||
export class InstallExtensionCommand implements Command {
|
||||
readonly name = 'extensions install';
|
||||
readonly description = 'Install an extension from a git repo or local path.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.agentContext.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot install extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const source = args.join(' ').trim();
|
||||
if (!source) {
|
||||
return { name: this.name, data: `Usage: /extensions install <source>` };
|
||||
}
|
||||
|
||||
if (/[;&|`'"]/.test(source)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Invalid source: contains disallowed characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const installMetadata = await inferInstallMetadata(source);
|
||||
const extension =
|
||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${extension.name}" installed successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkExtensionCommand implements Command {
|
||||
readonly name = 'extensions link';
|
||||
readonly description = 'Link an extension from a local path.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.agentContext.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot link extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const sourceFilepath = args.join(' ').trim();
|
||||
if (!sourceFilepath) {
|
||||
return { name: this.name, data: `Usage: /extensions link <source>` };
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(sourceFilepath);
|
||||
} catch {
|
||||
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = await extensionLoader.installOrUpdateExtension({
|
||||
source: sourceFilepath,
|
||||
type: 'link',
|
||||
});
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Extension "${extension.name}" linked successfully.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Failed to link extension: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UninstallExtensionCommand implements Command {
|
||||
readonly name = 'extensions uninstall';
|
||||
readonly description = 'Uninstall an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.agentContext.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Cannot uninstall extensions in this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const all = args.includes('--all');
|
||||
const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
|
||||
|
||||
if (!all && names.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Usage: /extensions uninstall <extension-names...>|--all`,
|
||||
};
|
||||
}
|
||||
|
||||
let namesToUninstall: string[] = [];
|
||||
if (all) {
|
||||
namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
|
||||
} else {
|
||||
namesToUninstall = names;
|
||||
}
|
||||
|
||||
if (namesToUninstall.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: all ? 'No extensions installed.' : 'No extension name provided.',
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const extensionName of namesToUninstall) {
|
||||
try {
|
||||
await extensionLoader.uninstallExtension(extensionName, false);
|
||||
output.push(`Extension "${extensionName}" uninstalled successfully.`);
|
||||
} catch (error) {
|
||||
output.push(
|
||||
`Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') };
|
||||
}
|
||||
}
|
||||
|
||||
export class RestartExtensionCommand implements Command {
|
||||
readonly name = 'extensions restart';
|
||||
readonly description = 'Restart an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.agentContext.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return { name: this.name, data: 'Cannot restart extensions.' };
|
||||
}
|
||||
|
||||
const all = args.includes('--all');
|
||||
const names = all ? null : args.filter((a) => !!a);
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Usage: /extensions restart <extension-names>|--all',
|
||||
};
|
||||
}
|
||||
|
||||
let extensionsToRestart = extensionLoader
|
||||
.getExtensions()
|
||||
.filter((e) => e.isActive);
|
||||
if (names) {
|
||||
extensionsToRestart = extensionsToRestart.filter((e) =>
|
||||
names.includes(e.name),
|
||||
);
|
||||
}
|
||||
|
||||
if (extensionsToRestart.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'No active extensions matched the request.',
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const extension of extensionsToRestart) {
|
||||
try {
|
||||
await extensionLoader.restartExtension(extension);
|
||||
output.push(`Restarted "${extension.name}".`);
|
||||
} catch (e) {
|
||||
output.push(
|
||||
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this.name, data: output.join('\n') };
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateExtensionCommand implements Command {
|
||||
readonly name = 'extensions update';
|
||||
readonly description = 'Update an extension.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensionLoader = context.agentContext.config.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
return { name: this.name, data: 'Cannot update extensions.' };
|
||||
}
|
||||
|
||||
const all = args.includes('--all');
|
||||
const names = all ? null : args.filter((a) => !!a);
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Usage: /extensions update <extension-names>|--all',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HelpCommand } from './help.js';
|
||||
import { CommandRegistry } from './commandRegistry.js';
|
||||
import type { Command, CommandContext } from './types.js';
|
||||
|
||||
describe('HelpCommand', () => {
|
||||
it('returns formatted help text with sorted commands', async () => {
|
||||
const registry = new CommandRegistry();
|
||||
|
||||
const cmdB: Command = {
|
||||
name: 'bravo',
|
||||
description: 'Bravo command',
|
||||
execute: async () => ({ name: 'bravo', data: '' }),
|
||||
};
|
||||
|
||||
const cmdA: Command = {
|
||||
name: 'alpha',
|
||||
description: 'Alpha command',
|
||||
execute: async () => ({ name: 'alpha', data: '' }),
|
||||
};
|
||||
|
||||
registry.register(cmdB);
|
||||
registry.register(cmdA);
|
||||
|
||||
const helpCommand = new HelpCommand(registry);
|
||||
|
||||
const context = {} as CommandContext;
|
||||
|
||||
const response = await helpCommand.execute(context, []);
|
||||
|
||||
expect(response.name).toBe('help');
|
||||
|
||||
const data = response.data as string;
|
||||
|
||||
expect(data).toContain('Gemini CLI Help:');
|
||||
expect(data).toContain('### Basics');
|
||||
expect(data).toContain('### Commands');
|
||||
|
||||
const lines = data.split('\n');
|
||||
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
|
||||
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
|
||||
|
||||
expect(alphaIndex).toBeLessThan(bravoIndex);
|
||||
expect(alphaIndex).not.toBe(-1);
|
||||
expect(bravoIndex).not.toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import type { CommandRegistry } from './commandRegistry.js';
|
||||
|
||||
export class HelpCommand implements Command {
|
||||
readonly name = 'help';
|
||||
readonly description = 'Show available commands';
|
||||
|
||||
constructor(private registry: CommandRegistry) {}
|
||||
|
||||
async execute(
|
||||
_context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const commands = this.registry
|
||||
.getAllCommands()
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Gemini CLI Help:');
|
||||
lines.push('');
|
||||
lines.push('### Basics');
|
||||
lines.push(
|
||||
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('### Commands');
|
||||
for (const cmd of commands) {
|
||||
if (cmd.description) {
|
||||
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { performInit } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class InitCommand implements Command {
|
||||
name = 'init';
|
||||
description = 'Analyzes the project and creates a tailored GEMINI.md file';
|
||||
requiresWorkspace = true;
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const targetDir = context.agentContext.config.getTargetDir();
|
||||
if (!targetDir) {
|
||||
throw new Error('Command requires a workspace.');
|
||||
}
|
||||
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
const result = performInit(fs.existsSync(geminiMdPath));
|
||||
|
||||
switch (result.type) {
|
||||
case 'message':
|
||||
return {
|
||||
name: this.name,
|
||||
data: result,
|
||||
};
|
||||
case 'submit_prompt':
|
||||
fs.writeFileSync(geminiMdPath, '', 'utf8');
|
||||
|
||||
if (typeof result.content !== 'string') {
|
||||
throw new Error('Init command content must be a string.');
|
||||
}
|
||||
|
||||
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
|
||||
// We output the prompt text they can use to re-trigger the generation manually,
|
||||
// or just seed the GEMINI.md file as we've done above.
|
||||
return {
|
||||
name: this.name,
|
||||
data: {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error('Unknown result type from performInit');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
listInboxMemoryPatches,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class MemoryCommand implements Command {
|
||||
readonly name = 'memory';
|
||||
readonly description = 'Manage memory.';
|
||||
readonly subCommands = [
|
||||
new ShowMemoryCommand(),
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new InboxMemoryCommand(),
|
||||
];
|
||||
readonly requiresWorkspace = true;
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ShowMemoryCommand().execute(context, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShowMemoryCommand implements Command {
|
||||
readonly name = 'memory show';
|
||||
readonly description = 'Shows the current memory contents.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = showMemory(context.agentContext.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class RefreshMemoryCommand implements Command {
|
||||
readonly name = 'memory refresh';
|
||||
readonly aliases = ['memory reload'];
|
||||
readonly description = 'Refreshes the memory from the source.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = await refreshMemory(context.agentContext.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class ListMemoryCommand implements Command {
|
||||
readonly name = 'memory list';
|
||||
readonly description = 'Lists the paths of the GEMINI.md files in use.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const result = listMemoryFiles(context.agentContext.config);
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
}
|
||||
|
||||
export class InboxMemoryCommand implements Command {
|
||||
readonly name = 'memory inbox';
|
||||
readonly description =
|
||||
'Lists memory items extracted from past sessions that are pending review.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
if (!context.agentContext.config.isAutoMemoryEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
const [skills, patches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(context.agentContext.config),
|
||||
listInboxPatches(context.agentContext.config),
|
||||
listInboxMemoryPatches(context.agentContext.config),
|
||||
]);
|
||||
|
||||
if (
|
||||
skills.length === 0 &&
|
||||
patches.length === 0 &&
|
||||
memoryPatches.length === 0
|
||||
) {
|
||||
return { name: this.name, data: 'No items in inbox.' };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const s of skills) {
|
||||
const date = s.extractedAt
|
||||
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
lines.push(`- **${s.name}**: ${s.description}${date}`);
|
||||
}
|
||||
for (const p of patches) {
|
||||
const targets = p.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = p.extractedAt
|
||||
? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
|
||||
}
|
||||
for (const memoryPatch of memoryPatches) {
|
||||
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = memoryPatch.extractedAt
|
||||
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
const sourceCount = memoryPatch.sourceFiles.length;
|
||||
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
|
||||
lines.push(
|
||||
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = skills.length + patches.length + memoryPatches.length;
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getCheckpointInfoList: vi.fn(),
|
||||
getToolCallDataSchema: vi.fn(),
|
||||
isNodeError: vi.fn(),
|
||||
performRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RestoreCommand', () => {
|
||||
let context: CommandContext;
|
||||
let restoreCommand: RestoreCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
restoreCommand = new RestoreCommand();
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
git: {},
|
||||
sendMessage: vi.fn(),
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('delegates to list behavior when invoked without args', async () => {
|
||||
const listExecuteSpy = vi
|
||||
.spyOn(ListCheckpointsCommand.prototype, 'execute')
|
||||
.mockResolvedValue({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
|
||||
const response = await restoreCommand.execute(context, []);
|
||||
|
||||
expect(listExecuteSpy).toHaveBeenCalledWith(context);
|
||||
expect(response).toEqual({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['checkpoint1']);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns file-not-found message for missing checkpoint', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['missing']);
|
||||
|
||||
expect(response.data).toBe('File not found: missing.json');
|
||||
});
|
||||
|
||||
it('handles checkpoint filename already ending in .json', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['existing.json']);
|
||||
|
||||
expect(response.data).toBe('File not found: existing.json');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('existing.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi.fn().mockReturnValue({ success: false }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['invalid']);
|
||||
|
||||
expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
|
||||
});
|
||||
|
||||
it('formats streamed restore results correctly', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, data: { some: 'data' } }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
async function* mockRestoreGenerator() {
|
||||
yield { type: 'message', messageType: 'info', content: 'Restoring...' };
|
||||
yield { type: 'load_history', clientHistory: [{}, {}] };
|
||||
yield { type: 'other', some: 'other' };
|
||||
}
|
||||
vi.mocked(performRestore).mockReturnValue(
|
||||
mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
|
||||
);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['valid']);
|
||||
|
||||
expect(response.data).toContain('[INFO] Restoring...');
|
||||
expect(response.data).toContain('Loaded history with 2 messages.');
|
||||
expect(response.data).toContain(
|
||||
'Restored: {"type":"other","some":"other"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message for non-ENOENT failures', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
|
||||
vi.mocked(isNodeError).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['error']);
|
||||
|
||||
expect(response.data).toContain(
|
||||
'An unexpected error occurred during restore: Error: Random error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListCheckpointsCommand', () => {
|
||||
let context: CommandContext;
|
||||
let listCommand: ListCheckpointsCommand;
|
||||
let mockReaddir: Mock<(path: string) => Promise<string[]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listCommand = new ListCheckpointsCommand();
|
||||
mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
|
||||
(path: string) => Promise<string[]>
|
||||
>;
|
||||
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
|
||||
mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
});
|
||||
|
||||
it('ignores error when mkdir fails', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats checkpoint summary output from checkpoint metadata', async () => {
|
||||
mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([
|
||||
{ messageId: 'id1', checkpoint: 'cp1' },
|
||||
{ messageId: 'id2', checkpoint: 'cp2' },
|
||||
]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Available Checkpoints:');
|
||||
// Note: The current implementation of ListCheckpointsCommand incorrectly accesses
|
||||
// fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
|
||||
expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
|
||||
});
|
||||
|
||||
it('handles empty checkpoint info list', async () => {
|
||||
mockReaddir.mockResolvedValue(['some.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('Available Checkpoints:\n');
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message on failures', async () => {
|
||||
mockReaddir.mockRejectedValue(new Error('Readdir fail'));
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe(
|
||||
'An unexpected error occurred while listing checkpoints.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
|
||||
export class RestoreCommand implements Command {
|
||||
readonly name = 'restore';
|
||||
readonly description =
|
||||
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
|
||||
readonly requiresWorkspace = true;
|
||||
readonly subCommands = [new ListCheckpointsCommand()];
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const { agentContext: agentContext, git: gitService } = context;
|
||||
const { config } = agentContext;
|
||||
const argsStr = args.join(' ');
|
||||
|
||||
try {
|
||||
if (!argsStr) {
|
||||
return await new ListCheckpointsCommand().execute(context);
|
||||
}
|
||||
|
||||
if (!config.getCheckpointingEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
|
||||
};
|
||||
}
|
||||
|
||||
const selectedFile = argsStr.endsWith('.json')
|
||||
? argsStr
|
||||
: `${argsStr}.json`;
|
||||
|
||||
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
|
||||
const filePath = path.join(checkpointDir, selectedFile);
|
||||
|
||||
let data: string;
|
||||
try {
|
||||
data = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `File not found: ${selectedFile}`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const toolCallData = JSON.parse(data);
|
||||
const ToolCallDataSchema = getToolCallDataSchema();
|
||||
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpoint file is invalid or corrupted.',
|
||||
};
|
||||
}
|
||||
|
||||
const restoreResultGenerator = performRestore(
|
||||
parseResult.data,
|
||||
gitService,
|
||||
);
|
||||
|
||||
const restoreResult = [];
|
||||
for await (const result of restoreResultGenerator) {
|
||||
restoreResult.push(result);
|
||||
}
|
||||
|
||||
// Format the result nicely since Zed just dumps data
|
||||
const formattedResult = restoreResult
|
||||
.map((r) => {
|
||||
if (r.type === 'message') {
|
||||
return `[${r.messageType.toUpperCase()}] ${r.content}`;
|
||||
} else if (r.type === 'load_history') {
|
||||
return `Loaded history with ${r.clientHistory.length} messages.`;
|
||||
}
|
||||
return `Restored: ${JSON.stringify(r)}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: formattedResult,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: `An unexpected error occurred during restore: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ListCheckpointsCommand implements Command {
|
||||
readonly name = 'restore list';
|
||||
readonly description = 'Lists all available checkpoints.';
|
||||
|
||||
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
|
||||
const { config } = context.agentContext;
|
||||
|
||||
try {
|
||||
if (!config.getCheckpointingEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
|
||||
};
|
||||
}
|
||||
|
||||
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
|
||||
try {
|
||||
await fs.mkdir(checkpointDir, { recursive: true });
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const files = await fs.readdir(checkpointDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
|
||||
if (jsonFiles.length === 0) {
|
||||
return { name: this.name, data: 'No checkpoints found.' };
|
||||
}
|
||||
|
||||
const checkpointFiles = new Map<string, string>();
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(checkpointDir, file);
|
||||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
checkpointFiles.set(file, data);
|
||||
}
|
||||
|
||||
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
|
||||
|
||||
const formatted = checkpointInfoList
|
||||
.map((info) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const i = info as Record<string, any>;
|
||||
const fileName = String(i['fileName'] || 'Unknown');
|
||||
const toolName = String(i['toolName'] || 'Unknown');
|
||||
const status = String(i['status'] || 'Unknown');
|
||||
const timestamp = new Date(
|
||||
Number(i['timestamp']) || 0,
|
||||
).toLocaleString();
|
||||
|
||||
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Available Checkpoints:\n${formatted}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'An unexpected error occurred while listing checkpoints.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AgentLoopContext, GitService } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export interface CommandContext {
|
||||
agentContext: AgentLoopContext;
|
||||
settings: LoadedSettings;
|
||||
git?: GitService;
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommandArgument {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
readonly name: string;
|
||||
readonly aliases?: string[];
|
||||
readonly description: string;
|
||||
readonly arguments?: CommandArgument[];
|
||||
readonly subCommands?: Command[];
|
||||
readonly requiresWorkspace?: boolean;
|
||||
|
||||
execute(
|
||||
context: CommandContext,
|
||||
args: string[],
|
||||
): Promise<CommandExecutionResponse>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionResponse {
|
||||
readonly name: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { extensionsCommand } from './extensions.js';
|
||||
|
||||
// Mock subcommands
|
||||
vi.mock('./extensions/install.js', () => ({
|
||||
installCommand: { command: 'install' },
|
||||
}));
|
||||
vi.mock('./extensions/uninstall.js', () => ({
|
||||
uninstallCommand: { command: 'uninstall' },
|
||||
}));
|
||||
vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
|
||||
vi.mock('./extensions/update.js', () => ({
|
||||
updateCommand: { command: 'update' },
|
||||
}));
|
||||
vi.mock('./extensions/disable.js', () => ({
|
||||
disableCommand: { command: 'disable' },
|
||||
}));
|
||||
vi.mock('./extensions/enable.js', () => ({
|
||||
enableCommand: { command: 'enable' },
|
||||
}));
|
||||
vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
|
||||
vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
|
||||
vi.mock('./extensions/validate.js', () => ({
|
||||
validateCommand: { command: 'validate' },
|
||||
}));
|
||||
|
||||
// Mock gemini.js
|
||||
vi.mock('../gemini.js', () => ({
|
||||
initializeOutputListenersAndFlush: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
it('should have correct command and aliases', () => {
|
||||
expect(extensionsCommand.command).toBe('extensions <command>');
|
||||
expect(extensionsCommand.aliases).toEqual(['extension']);
|
||||
expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
|
||||
});
|
||||
|
||||
it('should register all subcommands in builder', () => {
|
||||
const mockYargs = {
|
||||
middleware: vi.fn().mockReturnThis(),
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Mocking yargs
|
||||
extensionsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'install' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'uninstall' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'list' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'update' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'disable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'enable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'link' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'new' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'validate' }),
|
||||
);
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should have a handler that does nothing', () => {
|
||||
// @ts-expect-error - Handler doesn't take arguments in this case
|
||||
expect(extensionsCommand.handler()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { installCommand } from './extensions/install.js';
|
||||
import { uninstallCommand } from './extensions/uninstall.js';
|
||||
import { listCommand } from './extensions/list.js';
|
||||
import { updateCommand } from './extensions/update.js';
|
||||
import { disableCommand } from './extensions/disable.js';
|
||||
import { enableCommand } from './extensions/enable.js';
|
||||
import { linkCommand } from './extensions/link.js';
|
||||
import { newCommand } from './extensions/new.js';
|
||||
import { validateCommand } from './extensions/validate.js';
|
||||
import { configureCommand } from './extensions/configure.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
aliases: ['extension'],
|
||||
describe: 'Manage Gemini CLI extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(installCommand, 'extensions'))
|
||||
.command(defer(uninstallCommand, 'extensions'))
|
||||
.command(defer(listCommand, 'extensions'))
|
||||
.command(defer(updateCommand, 'extensions'))
|
||||
.command(defer(disableCommand, 'extensions'))
|
||||
.command(defer(enableCommand, 'extensions'))
|
||||
.command(defer(linkCommand, 'extensions'))
|
||||
.command(defer(newCommand, 'extensions'))
|
||||
.command(defer(validateCommand, 'extensions'))
|
||||
.command(defer(configureCommand, 'extensions'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { configureCommand } from './configure.js';
|
||||
import yargs from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
updateSetting,
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
const extensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
getExtensions: vi.fn(),
|
||||
loadExtensions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExtensionManager: extensionManager,
|
||||
mockGetExtensionManager: vi.fn(),
|
||||
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
updateSetting: vi.fn(),
|
||||
promptForSetting: vi.fn(),
|
||||
getScopedEnvContents: vi.fn(),
|
||||
ExtensionSettingScope: {
|
||||
USER: 'user',
|
||||
WORKSPACE: 'workspace',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getExtensionManager: mockGetExtensionManager,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('prompts');
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: mockLoadSettings,
|
||||
}));
|
||||
|
||||
describe('extensions configure command', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(debugLogger, 'log');
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
|
||||
(ExtensionManager as unknown as Mock).mockImplementation(
|
||||
() => mockExtensionManager,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(tempWorkspaceDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const runCommand = async (command: string) => {
|
||||
const parser = yargs().command(configureCommand).help(false).version(false);
|
||||
await parser.parse(command);
|
||||
};
|
||||
|
||||
const setupExtension = (
|
||||
name: string,
|
||||
settings: Array<Partial<ExtensionSetting>> = [],
|
||||
id = 'test-id',
|
||||
path = '/test/path',
|
||||
) => {
|
||||
const extension = { name, path, id };
|
||||
|
||||
mockExtensionManager.getExtensions.mockReturnValue([extension]);
|
||||
mockExtensionManager.loadExtensionConfig.mockResolvedValue({
|
||||
name,
|
||||
settings,
|
||||
});
|
||||
return extension;
|
||||
};
|
||||
|
||||
describe('Specific setting configuration', () => {
|
||||
it('should configure a specific setting', async () => {
|
||||
setupExtension('test-ext', [
|
||||
{ name: 'Test Setting', envVar: 'TEST_VAR' },
|
||||
]);
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext TEST_VAR');
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'TEST_VAR',
|
||||
expect.any(Function),
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing extension', async () => {
|
||||
mockExtensionManager.getExtensions.mockReturnValue([]);
|
||||
|
||||
await runCommand('config missing-ext TEST_VAR');
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid extension names', async () => {
|
||||
await runCommand('config ../invalid TEST_VAR');
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid extension name'),
|
||||
);
|
||||
|
||||
await runCommand('config ext/with/slash TEST_VAR');
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid extension name'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extension configuration (all settings)', () => {
|
||||
it('should configure all settings for an extension', async () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockResolvedValue({});
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
'Configuring settings for "test-ext"...',
|
||||
);
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'VAR_1',
|
||||
expect.any(Function),
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify overwrite if setting is already set', async () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockImplementation(
|
||||
async (_config, _id, scope) => {
|
||||
if (scope === 'user') return { VAR_1: 'existing' };
|
||||
return {};
|
||||
},
|
||||
);
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: true });
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
expect(prompts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'confirm',
|
||||
message: expect.stringContaining('is already set. Overwrite?'),
|
||||
}),
|
||||
);
|
||||
expect(updateSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should note if setting is configured in workspace', async () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockImplementation(
|
||||
async (_config, _id, scope) => {
|
||||
if (scope === 'workspace') return { VAR_1: 'workspace_value' };
|
||||
return {};
|
||||
},
|
||||
);
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('is already configured in the workspace scope'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip update if user denies overwrite', async () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: false });
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
expect(prompts).toHaveBeenCalled();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configure all extensions', () => {
|
||||
it('should configure settings for all installed extensions', async () => {
|
||||
const ext1 = {
|
||||
name: 'ext1',
|
||||
path: '/p1',
|
||||
id: 'id1',
|
||||
settings: [{ envVar: 'V1' }],
|
||||
};
|
||||
const ext2 = {
|
||||
name: 'ext2',
|
||||
path: '/p2',
|
||||
id: 'id2',
|
||||
settings: [{ envVar: 'V2' }],
|
||||
};
|
||||
mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]);
|
||||
|
||||
mockExtensionManager.loadExtensionConfig.mockImplementation(
|
||||
async (path) => {
|
||||
if (path === '/p1')
|
||||
return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] };
|
||||
if (path === '/p2')
|
||||
return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] };
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
(getScopedEnvContents as Mock).mockResolvedValue({});
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config');
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Configuring settings for "ext1"'),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Configuring settings for "ext2"'),
|
||||
);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should log if no extensions installed', async () => {
|
||||
mockExtensionManager.getExtensions.mockReturnValue([]);
|
||||
await runCommand('config');
|
||||
expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import {
|
||||
configureAllExtensions,
|
||||
configureExtension,
|
||||
configureSpecificSetting,
|
||||
getExtensionManager,
|
||||
} from './utils.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface ConfigureArgs {
|
||||
name?: string;
|
||||
setting?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
command: 'config [name] [setting]',
|
||||
describe: 'Configure extension settings.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'Name of the extension to configure.',
|
||||
type: 'string',
|
||||
})
|
||||
.positional('setting', {
|
||||
describe: 'The specific setting to configure (name or env var).',
|
||||
type: 'string',
|
||||
})
|
||||
.option('scope', {
|
||||
describe: 'The scope to set the setting in.',
|
||||
type: 'string',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name, setting, scope } = args;
|
||||
const settings = loadSettings(process.cwd()).merged;
|
||||
|
||||
if (!(settings.experimental?.extensionConfig ?? true)) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
|
||||
);
|
||||
await exitCli();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
debugLogger.error(
|
||||
'Invalid extension name. Names cannot contain path separators or "..".',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = await getExtensionManager();
|
||||
|
||||
// Case 1: Configure specific setting for an extension
|
||||
if (name && setting) {
|
||||
await configureSpecificSetting(
|
||||
extensionManager,
|
||||
name,
|
||||
setting,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
// Case 2: Configure all settings for an extension
|
||||
else if (name) {
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
name,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
// Case 3: Configure all extensions
|
||||
else {
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleDisable, disableCommand } from './disable.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
error: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('error', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
promptForSetting: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions disable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockGetErrorMessage = vi.mocked(getErrorMessage);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.disableExtension = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleDisable', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'my-extension',
|
||||
scope: undefined,
|
||||
expectedScope: SettingScope.User,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully disabled for scope "undefined".',
|
||||
},
|
||||
{
|
||||
name: 'my-extension',
|
||||
scope: 'user',
|
||||
expectedScope: SettingScope.User,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully disabled for scope "user".',
|
||||
},
|
||||
{
|
||||
name: 'my-extension',
|
||||
scope: 'workspace',
|
||||
expectedScope: SettingScope.Workspace,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully disabled for scope "workspace".',
|
||||
},
|
||||
])(
|
||||
'should disable an extension in the $expectedScope scope when scope is $scope',
|
||||
async ({ name, scope, expectedScope, expectedLog }) => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleDisable({ name, scope });
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mockExtensionManager.prototype.loadExtensions,
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
mockExtensionManager.prototype.disableExtension,
|
||||
).toHaveBeenCalledWith(name, expectedScope);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
|
||||
mockCwd.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it('should log an error message and exit with code 1 when extension disabling fails', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Disable failed');
|
||||
(
|
||||
mockExtensionManager.prototype.disableExtension as Mock
|
||||
).mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Disable failed message');
|
||||
await handleDisable({ name: 'my-extension' });
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Disable failed message',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableCommand', () => {
|
||||
const command = disableCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('disable [--scope] <name>');
|
||||
expect(command.describe).toBe('Disables an extension.');
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
let yargsMock: MockYargs;
|
||||
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure positional and option arguments', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
|
||||
describe: 'The name of the extension to disable.',
|
||||
type: 'string',
|
||||
});
|
||||
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
|
||||
describe: 'The scope to disable the extension in.',
|
||||
type: 'string',
|
||||
default: SettingScope.User,
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw for invalid scope', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`;
|
||||
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
|
||||
expectedError,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['user', 'workspace', 'USER', 'WorkSpace'])(
|
||||
'check function should return true for valid scope "%s"',
|
||||
(scope) => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(checkCallback({ scope })).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('handler should trigger extension disabling', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
name: string;
|
||||
scope: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const argv: TestArgv = {
|
||||
name: 'test-ext',
|
||||
scope: 'workspace',
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
expect(
|
||||
mockExtensionManager.prototype.disableExtension,
|
||||
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "test-ext" successfully disabled for scope "workspace".',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface DisableArgs {
|
||||
name: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export async function handleDisable(args: DisableArgs) {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
try {
|
||||
if (args.scope?.toLowerCase() === 'workspace') {
|
||||
await extensionManager.disableExtension(
|
||||
args.name,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
} else {
|
||||
await extensionManager.disableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable [--scope] <name>',
|
||||
describe: 'Disables an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to disable.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('scope', {
|
||||
describe: 'The scope to disable the extension in.',
|
||||
type: 'string',
|
||||
default: SettingScope.User,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (
|
||||
argv.scope &&
|
||||
!Object.values(SettingScope)
|
||||
.map((s) => s.toLowerCase())
|
||||
.includes(argv.scope.toLowerCase())
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleEnable, enableCommand } from './enable.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { FatalConfigError } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
error: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('error', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((error: { message: string }) => error.message),
|
||||
FatalConfigError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalConfigError';
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extensions/consent.js');
|
||||
vi.mock('../../config/extensions/extensionSettings.js');
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnablementInstance = vi.hoisted(() => ({
|
||||
getDisplayState: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
clearSessionDisable: vi.fn(),
|
||||
autoEnableServers: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({
|
||||
McpServerEnablementManager: {
|
||||
getInstance: () => mockEnablementInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions enable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.enableExtension = vi.fn();
|
||||
mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
mockEnablementInstance.getDisplayState.mockReset();
|
||||
mockEnablementInstance.enable.mockReset();
|
||||
mockEnablementInstance.clearSessionDisable.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleEnable', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'my-extension',
|
||||
scope: undefined,
|
||||
expectedScope: SettingScope.User,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully enabled in all scopes.',
|
||||
},
|
||||
{
|
||||
name: 'my-extension',
|
||||
scope: 'workspace',
|
||||
expectedScope: SettingScope.Workspace,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully enabled for scope "workspace".',
|
||||
},
|
||||
])(
|
||||
'should enable an extension in the $expectedScope scope when scope is $scope',
|
||||
async ({ name, scope, expectedScope, expectedLog }) => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleEnable({ name, scope });
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mockExtensionManager.prototype.loadExtensions,
|
||||
).toHaveBeenCalled();
|
||||
expect(
|
||||
mockExtensionManager.prototype.enableExtension,
|
||||
).toHaveBeenCalledWith(name, expectedScope);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
|
||||
mockCwd.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it('should throw FatalConfigError when extension enabling fails', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const error = new Error('Enable failed');
|
||||
(
|
||||
mockExtensionManager.prototype.enableExtension as Mock
|
||||
).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const promise = handleEnable({ name: 'my-extension' });
|
||||
await expect(promise).rejects.toThrow(FatalConfigError);
|
||||
await expect(promise).rejects.toThrow('Enable failed');
|
||||
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should auto-enable disabled MCP servers for the extension', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([
|
||||
'test-server',
|
||||
]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log when MCP servers are already enabled', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).not.toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableCommand', () => {
|
||||
const command = enableCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('enable [--scope] <name>');
|
||||
expect(command.describe).toBe('Enables an extension.');
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
let yargsMock: MockYargs;
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure positional and option arguments', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
|
||||
describe: 'The name of the extension to enable.',
|
||||
type: 'string',
|
||||
});
|
||||
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
|
||||
describe:
|
||||
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
|
||||
type: 'string',
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw for invalid scope', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`;
|
||||
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
|
||||
expectedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleEnable', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
name: string;
|
||||
scope: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const argv: TestArgv = {
|
||||
name: 'test-ext',
|
||||
scope: 'workspace',
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.prototype.enableExtension,
|
||||
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
debugLogger,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
|
||||
interface EnableArgs {
|
||||
name: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export async function handleEnable(args: EnableArgs) {
|
||||
const workingDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir: workingDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workingDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
try {
|
||||
if (args.scope?.toLowerCase() === 'workspace') {
|
||||
await extensionManager.enableExtension(args.name, SettingScope.Workspace);
|
||||
} else {
|
||||
await extensionManager.enableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
|
||||
// Auto-enable any disabled MCP servers for this extension
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === args.name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers ?? {}),
|
||||
);
|
||||
|
||||
for (const serverName of enabledServers) {
|
||||
debugLogger.log(
|
||||
`MCP server '${serverName}' was disabled - now enabled.`,
|
||||
);
|
||||
}
|
||||
// Note: No restartServer() - CLI exits immediately, servers load on next session
|
||||
}
|
||||
|
||||
if (args.scope) {
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully enabled in all scopes.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new FatalConfigError(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable [--scope] <name>',
|
||||
describe: 'Enables an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to enable.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
|
||||
type: 'string',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (
|
||||
argv.scope &&
|
||||
!Object.values(SettingScope)
|
||||
.map((s) => s.toLowerCase())
|
||||
.includes(argv.scope.toLowerCase())
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
|
||||
SettingScope,
|
||||
)
|
||||
.map((s) => s.toLowerCase())
|
||||
.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "custom-commands",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "excludeTools",
|
||||
"version": "1.0.0",
|
||||
"excludeTools": ["run_shell_command(rm -rf)"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "hooks-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${extensionPath}/scripts/on-start.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
console.log(
|
||||
'Session Started! This is running from a script in the hooks-example extension.',
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,35 @@
|
||||
# MCP Server Example
|
||||
|
||||
This is a basic example of an MCP (Model Context Protocol) server used as a
|
||||
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
|
||||
Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
The contents of this directory are a valid MCP server implementation using the
|
||||
`@modelcontextprotocol/sdk`. It exposes:
|
||||
|
||||
- A tool `fetch_posts` that mock-fetches posts.
|
||||
- A prompt `poem-writer`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `example.js`: The main server entry point.
|
||||
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
|
||||
use this extension.
|
||||
- `package.json`: Helper for dependencies.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Navigate to this directory:
|
||||
|
||||
```bash
|
||||
cd packages/cli/src/commands/extensions/examples/mcp-server
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This example is typically used by `gemini extensions new`.
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
async () => {
|
||||
const apiResponse = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
const posts = await apiResponse.json();
|
||||
const response = { posts: posts.slice(0, 5) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'poem-writer',
|
||||
{
|
||||
title: 'Poem Writer',
|
||||
description: 'Write a nice haiku',
|
||||
argsSchema: { title: z.string(), mood: z.string().optional() },
|
||||
},
|
||||
({ title, mood }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example MCP Server for Gemini CLI Extension",
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Policy engine example extension
|
||||
|
||||
This extension demonstrates how to contribute security rules and safety checkers
|
||||
to the Gemini CLI Policy Engine.
|
||||
|
||||
## Description
|
||||
|
||||
The extension uses a `policies/` directory containing `.toml` files to define:
|
||||
|
||||
- A rule that requires user confirmation for `rm -rf` commands.
|
||||
- A rule that denies searching for sensitive files (like `.env`) using `grep`.
|
||||
- A safety checker that validates file paths for all write operations.
|
||||
|
||||
## Structure
|
||||
|
||||
- `gemini-extension.json`: The manifest file.
|
||||
- `policies/`: Contains the `.toml` policy files.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Link this extension to your local Gemini CLI installation:
|
||||
|
||||
```bash
|
||||
gemini extensions link packages/cli/src/commands/extensions/examples/policies
|
||||
```
|
||||
|
||||
2. Restart your Gemini CLI session.
|
||||
|
||||
3. **Observe the policies:**
|
||||
- Try asking the model to delete a directory: The policy engine will prompt
|
||||
you for confirmation due to the `rm -rf` rule.
|
||||
- Try asking the model to search for secrets: The `grep` rule will deny the
|
||||
request and display the custom deny message.
|
||||
- Any file write operation will now be processed through the `allowed-path`
|
||||
safety checker.
|
||||
|
||||
## Security note
|
||||
|
||||
For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
|
||||
configurations contributed by extensions. This ensures that extensions can
|
||||
strengthen security but cannot bypass user confirmation.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "policy-example",
|
||||
"version": "1.0.0",
|
||||
"description": "An example extension demonstrating Policy Engine support."
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Example Policy Rules for Gemini CLI Extension
|
||||
#
|
||||
# Extensions run in Tier 2 (Extension Tier).
|
||||
# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
|
||||
|
||||
# Rule: Always ask the user before running a specific dangerous shell command.
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "rm -rf"
|
||||
decision = "ask_user"
|
||||
priority = 100
|
||||
|
||||
# Rule: Deny access to sensitive files using the grep tool.
|
||||
[[rule]]
|
||||
toolName = "grep_search"
|
||||
argsPattern = "(\.env|id_rsa|passwd)"
|
||||
decision = "deny"
|
||||
priority = 200
|
||||
denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension."
|
||||
|
||||
# Safety Checker: Apply path validation to all write operations.
|
||||
[[safety_checker]]
|
||||
toolName = ["write_file", "replace"]
|
||||
priority = 300
|
||||
[safety_checker.checker]
|
||||
type = "in-process"
|
||||
name = "allowed-path"
|
||||
required_context = ["environment"]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "skills-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: greeter
|
||||
description: A friendly greeter skill
|
||||
---
|
||||
|
||||
You are a friendly greeter. When the user says "hello" or asks for a greeting,
|
||||
you should reply with: "Greetings from the skills-example extension! 👋"
|
||||
@@ -0,0 +1,31 @@
|
||||
# Themes Example
|
||||
|
||||
This is an example of a Gemini CLI extension that adds a custom theme.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Link this extension:
|
||||
|
||||
```bash
|
||||
gemini extensions link packages/cli/src/commands/extensions/examples/themes-example
|
||||
```
|
||||
|
||||
2. Set the theme in your settings file (`~/.gemini/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"theme": "shades-of-green (themes-example)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can set it through the UI by running `gemini` and then
|
||||
typing `/theme` and pressing Enter.
|
||||
|
||||
3. **Observe the Changes:**
|
||||
|
||||
After setting the theme, you should see the changes reflected in the Gemini
|
||||
CLI's UI. The background will be a dark green, the primary text a lighter
|
||||
green, and various other UI elements will display different shades of green,
|
||||
as defined in this extension's `gemini-extension.json` file.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "themes-example",
|
||||
"version": "1.0.0",
|
||||
"themes": [
|
||||
{
|
||||
"name": "shades-of-green",
|
||||
"type": "custom",
|
||||
"background": {
|
||||
"primary": "#1a362a"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#a6e3a1",
|
||||
"secondary": "#6e8e7a",
|
||||
"link": "#89e689"
|
||||
},
|
||||
"status": {
|
||||
"success": "#76c076",
|
||||
"warning": "#d9e689",
|
||||
"error": "#b34e4e"
|
||||
},
|
||||
"border": {
|
||||
"default": "#4a6c5a"
|
||||
},
|
||||
"ui": {
|
||||
"comment": "#6e8e7a"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import * as core from '@google/gemini-cli-core';
|
||||
import type { Stats } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
const {
|
||||
mockInstallOrUpdateExtension,
|
||||
mockLoadExtensions,
|
||||
mockExtensionManager,
|
||||
mockRequestConsentNonInteractive,
|
||||
mockPromptForConsentNonInteractive,
|
||||
mockStat,
|
||||
mockInferInstallMetadata,
|
||||
mockIsWorkspaceTrusted,
|
||||
mockLoadTrustedFolders,
|
||||
mockDiscover,
|
||||
} = vi.hoisted(() => {
|
||||
const mockLoadExtensions = vi.fn();
|
||||
const mockInstallOrUpdateExtension = vi.fn();
|
||||
const mockExtensionManager = vi.fn().mockImplementation(() => ({
|
||||
loadExtensions: mockLoadExtensions,
|
||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||
}));
|
||||
|
||||
return {
|
||||
mockLoadExtensions,
|
||||
mockInstallOrUpdateExtension,
|
||||
mockExtensionManager,
|
||||
mockRequestConsentNonInteractive: vi.fn(),
|
||||
mockPromptForConsentNonInteractive: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
mockInferInstallMetadata: vi.fn(),
|
||||
mockIsWorkspaceTrusted: vi.fn(),
|
||||
mockLoadTrustedFolders: vi.fn(),
|
||||
mockDiscover: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
|
||||
INSTALL_WARNING_MESSAGE: 'warning',
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: mockIsWorkspaceTrusted,
|
||||
loadTrustedFolders: mockLoadTrustedFolders,
|
||||
TrustLevel: {
|
||||
TRUST_FOLDER: 'TRUST_FOLDER',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
FolderTrustDiscoveryService: {
|
||||
discover: mockDiscover,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import('../../config/extension-manager.js')
|
||||
>()),
|
||||
ExtensionManager: mockExtensionManager,
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: mockStat,
|
||||
default: {
|
||||
stat: mockStat,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInstall', () => {
|
||||
let debugLogSpy: MockInstance;
|
||||
let debugErrorSpy: MockInstance;
|
||||
let processSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
debugLogSpy = vi
|
||||
.spyOn(core.debugLogger, 'log')
|
||||
.mockImplementation(() => {});
|
||||
debugErrorSpy = vi
|
||||
.spyOn(core.debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
mockLoadExtensions.mockResolvedValue([]);
|
||||
mockInstallOrUpdateExtension.mockReset();
|
||||
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
securityWarnings: [],
|
||||
discoveryErrors: [],
|
||||
});
|
||||
|
||||
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args?.ref,
|
||||
autoUpdate: args?.autoUpdate,
|
||||
allowPreRelease: args?.allowPreRelease,
|
||||
};
|
||||
}
|
||||
return { source, type: 'local' };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createMockExtension(
|
||||
overrides: Partial<core.GeminiCLIExtension> = {},
|
||||
): core.GeminiCLIExtension {
|
||||
return {
|
||||
name: 'mock-extension',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/mock/path',
|
||||
contextFiles: [],
|
||||
id: 'mock-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('should install an extension from a http source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'http-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "http-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should install an extension from a https source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'https-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://google.com',
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "https-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should install an extension from a git source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'git-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'git@some-url',
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "git-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error from an unknown source', async () => {
|
||||
mockInferInstallMetadata.mockRejectedValue(
|
||||
new Error('Install source not found.'),
|
||||
);
|
||||
await handleInstall({
|
||||
source: 'test://google.com',
|
||||
});
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.');
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should install an extension from a sso source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'sso-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'sso://google.com',
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "sso-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
await handleInstall({
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if install extension fails', async () => {
|
||||
mockInstallOrUpdateExtension.mockRejectedValue(
|
||||
new Error('Install extension failed'),
|
||||
);
|
||||
|
||||
await handleInstall({ source: 'git@some-url' });
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should pass promptForSetting when skipSettings is not provided', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'test-extension',
|
||||
} as unknown as core.GeminiCLIExtension);
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
});
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestSetting: promptForSetting,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass null for requestSetting when skipSettings is true', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'test-extension',
|
||||
} as unknown as core.GeminiCLIExtension);
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
skipSettings: true,
|
||||
});
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestSetting: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should proceed if local path is already trusted', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and proceed if user accepts trust', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
const mockSetValue = vi.fn();
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: mockSetValue,
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'untrusted', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join('untrusted', 'path')),
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and abort if user denies trust', async () => {
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(false);
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'evil', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installation aborted: Folder'),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should include discovery results in trust prompt', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: ['custom-cmd'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: ['cool-skill'],
|
||||
agents: ['cool-agent'],
|
||||
settings: [],
|
||||
securityWarnings: ['Security risk!'],
|
||||
discoveryErrors: ['Read error'],
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: vi.fn(),
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: '/untrusted/path',
|
||||
});
|
||||
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('This folder contains:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('custom-cmd'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cool-skill'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cool-agent'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security Warnings:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security risk!'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Discovery Errors:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Read error'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Implementation completed.
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as path from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
TrustLevel,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface InstallArgs {
|
||||
source: string;
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
consent?: boolean;
|
||||
skipSettings?: boolean;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
const { source } = args;
|
||||
const installMetadata = await inferInstallMetadata(source, {
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
});
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||
const absolutePath = path.resolve(source);
|
||||
const realPath = getRealPath(absolutePath);
|
||||
installMetadata.source = absolutePath;
|
||||
const trustResult = isWorkspaceTrusted(settings, absolutePath);
|
||||
if (trustResult.isTrusted !== true) {
|
||||
const discoveryResults =
|
||||
await FolderTrustDiscoveryService.discover(realPath);
|
||||
|
||||
const hasDiscovery =
|
||||
discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0;
|
||||
|
||||
const promptLines = [
|
||||
'',
|
||||
chalk.bold('Do you trust the files in this folder?'),
|
||||
'',
|
||||
`The extension source at "${absolutePath}" is not trusted.`,
|
||||
'',
|
||||
'Trusting a folder allows Gemini CLI to load its local configurations,',
|
||||
'including custom commands, hooks, MCP servers, agent skills, and',
|
||||
'settings. These configurations could execute code on your behalf or',
|
||||
'change the behavior of the CLI.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (discoveryResults.discoveryErrors.length > 0) {
|
||||
promptLines.push(chalk.red('❌ Discovery Errors:'));
|
||||
for (const error of discoveryResults.discoveryErrors) {
|
||||
promptLines.push(chalk.red(` • ${error}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (discoveryResults.securityWarnings.length > 0) {
|
||||
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
|
||||
for (const warning of discoveryResults.securityWarnings) {
|
||||
promptLines.push(chalk.yellow(` • ${warning}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (hasDiscovery) {
|
||||
promptLines.push(chalk.bold('This folder contains:'));
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults.commands ?? [] },
|
||||
{ label: 'MCP Servers', items: discoveryResults.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults.skills ?? [] },
|
||||
{ label: 'Agents', items: discoveryResults.agents ?? [] },
|
||||
{
|
||||
label: 'Setting overrides',
|
||||
items: discoveryResults.settings ?? [],
|
||||
},
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
for (const group of groups) {
|
||||
promptLines.push(
|
||||
` • ${chalk.bold(group.label)} (${group.items.length}):`,
|
||||
);
|
||||
for (const item of group.items) {
|
||||
promptLines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
promptLines.push(
|
||||
chalk.yellow(
|
||||
'Do you want to trust this folder and continue with the installation? [y/N]: ',
|
||||
),
|
||||
);
|
||||
|
||||
const confirmed = await promptForConsentNonInteractive(
|
||||
promptLines.join('\n'),
|
||||
false,
|
||||
);
|
||||
if (confirmed) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Installation aborted: Folder "${absolutePath}" is not trusted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve(true)
|
||||
: requestConsentNonInteractive;
|
||||
if (args.consent) {
|
||||
debugLogger.log('You have consented to the following:');
|
||||
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent,
|
||||
requestSetting: args.skipSettings ? null : promptForSetting,
|
||||
settings,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
const extension =
|
||||
await extensionManager.installOrUpdateExtension(installMetadata);
|
||||
debugLogger.log(
|
||||
`Extension "${extension.name}" installed successfully and enabled.`,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install <source> [--auto-update] [--pre-release]',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('source', {
|
||||
describe: 'The github URL or local path of the extension to install.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('ref', {
|
||||
describe: 'The git ref to install from.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('auto-update', {
|
||||
describe: 'Enable auto-update for this extension.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('pre-release', {
|
||||
describe: 'Enable pre-release versions for this extension.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.option('skip-settings', {
|
||||
describe: 'Skip the configuration on install process.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.source) {
|
||||
throw new Error('The source argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
source: argv['source'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: argv['ref'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
skipSettings: argv['skip-settings'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
promptForSetting: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions link command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockGetErrorMessage = vi.mocked(getErrorMessage);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.installOrUpdateExtension = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'my-linked-extension' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleLink', () => {
|
||||
it('should link an extension from a local path', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleLink({ path: '/local/path/to/extension' });
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
expect(
|
||||
mockExtensionManager.prototype.installOrUpdateExtension,
|
||||
).toHaveBeenCalledWith({
|
||||
source: '/local/path/to/extension',
|
||||
type: 'link',
|
||||
});
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "my-linked-extension" linked successfully and enabled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when linking fails', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Link failed');
|
||||
(
|
||||
mockExtensionManager.prototype.installOrUpdateExtension as Mock
|
||||
).mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Link failed message');
|
||||
|
||||
await handleLink({ path: '/local/path/to/extension' });
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Link failed message',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkCommand', () => {
|
||||
const command = linkCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('link <path>');
|
||||
expect(command.describe).toBe(
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.',
|
||||
);
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
let yargsMock: MockYargs;
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure positional argument', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('path', {
|
||||
describe: 'The name of the extension to link.',
|
||||
type: 'string',
|
||||
});
|
||||
expect(yargsMock.option).toHaveBeenCalledWith('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleLink', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
path: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const argv: TestArgv = {
|
||||
path: '/local/path/to/extension',
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.prototype.installOrUpdateExtension,
|
||||
).toHaveBeenCalledWith({
|
||||
source: '/local/path/to/extension',
|
||||
type: 'link',
|
||||
});
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface InstallArgs {
|
||||
path: string;
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
export async function handleLink(args: InstallArgs) {
|
||||
try {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: args.path,
|
||||
type: 'link',
|
||||
};
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve(true)
|
||||
: requestConsentNonInteractive;
|
||||
if (args.consent) {
|
||||
debugLogger.log('You have consented to the following:');
|
||||
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
||||
}
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
const extension =
|
||||
await extensionManager.installOrUpdateExtension(installMetadata);
|
||||
debugLogger.log(
|
||||
chalk.green(
|
||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The name of the extension to link.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((_) => true),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
promptForSetting: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions list command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockGetErrorMessage = vi.mocked(getErrorMessage);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleList', () => {
|
||||
it('should log a message if no extensions are installed', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList();
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No extensions installed.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should output empty JSON array if no extensions are installed and output-format is json', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]');
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
mockExtensionManager.prototype.toOutputString = vi.fn(
|
||||
(ext) => `${ext.name}@${ext.version}`,
|
||||
);
|
||||
await handleList();
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'ext1@1.0.0\n\next2@2.0.0',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions in JSON format', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
JSON.stringify(extensions, null, 2),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when listing fails', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('List failed');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('List failed message');
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'List failed message',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCommand', () => {
|
||||
const command = listCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('list');
|
||||
expect(command.describe).toBe('Lists installed extensions.');
|
||||
});
|
||||
|
||||
it('builder should have output-format option', () => {
|
||||
const mockYargs = {
|
||||
option: vi.fn().mockReturnThis(),
|
||||
};
|
||||
(
|
||||
command.builder as unknown as (
|
||||
yargs: typeof mockYargs,
|
||||
) => typeof mockYargs
|
||||
)(mockYargs);
|
||||
expect(mockYargs.option).toHaveBeenCalledWith('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleList with parsed arguments', async () => {
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await (
|
||||
command.handler as unknown as (args: {
|
||||
'output-format': string;
|
||||
}) => Promise<void>
|
||||
)({
|
||||
'output-format': 'json',
|
||||
});
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export async function handleList(options?: { outputFormat?: 'text' | 'json' }) {
|
||||
try {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
if (extensions.length === 0) {
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log('[]');
|
||||
} else {
|
||||
debugLogger.log('No extensions installed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log(JSON.stringify(extensions, null, 2));
|
||||
} else {
|
||||
debugLogger.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'Lists installed extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { newCommand } from './new.js';
|
||||
import yargs from 'yargs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedFs = vi.mocked(fsPromises);
|
||||
|
||||
describe('extensions new command', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
const fakeFiles = [
|
||||
{ name: 'context', isDirectory: () => true },
|
||||
{ name: 'custom-commands', isDirectory: () => true },
|
||||
{ name: 'mcp-server', isDirectory: () => true },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockedFs.readdir.mockResolvedValue(fakeFiles as any);
|
||||
});
|
||||
|
||||
it('should fail if no path is provided', async () => {
|
||||
const parser = yargs([]).command(newCommand).fail(false).locale('en');
|
||||
await expect(parser.parseAsync('new')).rejects.toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create directory when no template is provided', async () => {
|
||||
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
|
||||
mockedFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
|
||||
await parser.parseAsync('new /some/path');
|
||||
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockedFs.cp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create directory and copy files when path does not exist', async () => {
|
||||
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
|
||||
mockedFs.mkdir.mockResolvedValue(undefined);
|
||||
mockedFs.cp.mockResolvedValue(undefined);
|
||||
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
|
||||
await parser.parseAsync('new /some/path context');
|
||||
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.normalize('context/context')),
|
||||
path.normalize('/some/path/context'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.normalize('context/custom-commands')),
|
||||
path.normalize('/some/path/custom-commands'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.normalize('context/mcp-server')),
|
||||
path.normalize('/some/path/mcp-server'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the path already exists', async () => {
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
|
||||
await expect(parser.parseAsync('new /some/path context')).rejects.toThrow(
|
||||
'Path already exists: /some/path',
|
||||
);
|
||||
|
||||
expect(mockedFs.mkdir).not.toHaveBeenCalled();
|
||||
expect(mockedFs.cp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface NewArgs {
|
||||
path: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const EXAMPLES_PATH = join(__dirname, 'examples');
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDirectory(path: string) {
|
||||
if (await pathExists(path)) {
|
||||
throw new Error(`Path already exists: ${path}`);
|
||||
}
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDirectory(template: string, path: string) {
|
||||
await createDirectory(path);
|
||||
|
||||
const examplePath = join(EXAMPLES_PATH, template);
|
||||
const entries = await readdir(examplePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(examplePath, entry.name);
|
||||
const destPath = join(path, entry.name);
|
||||
await cp(srcPath, destPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNew(args: NewArgs) {
|
||||
if (args.template) {
|
||||
await copyDirectory(args.template, args.path);
|
||||
debugLogger.log(
|
||||
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
|
||||
);
|
||||
} else {
|
||||
await createDirectory(args.path);
|
||||
const extensionName = basename(args.path);
|
||||
const manifest = {
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
};
|
||||
await writeFile(
|
||||
join(args.path, 'gemini-extension.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
debugLogger.log(`Successfully created new extension at ${args.path}.`);
|
||||
}
|
||||
debugLogger.log(
|
||||
`You can install this using "gemini extensions link ${args.path}" to test it out.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getBoilerplateChoices() {
|
||||
const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
export const newCommand: CommandModule = {
|
||||
command: 'new <path> [template]',
|
||||
describe: 'Create a new extension from a boilerplate example.',
|
||||
builder: async (yargs) => {
|
||||
const choices = await getBoilerplateChoices();
|
||||
return yargs
|
||||
.positional('path', {
|
||||
describe: 'The path to create the extension in.',
|
||||
type: 'string',
|
||||
})
|
||||
.positional('template', {
|
||||
describe: 'The boilerplate template to use.',
|
||||
type: 'string',
|
||||
choices,
|
||||
});
|
||||
},
|
||||
handler: async (args) => {
|
||||
await handleNew({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
template: args['template'] as string | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
|
||||
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
|
||||
// The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary
|
||||
// for testing partial failure scenarios in the multiple extension uninstall feature.
|
||||
|
||||
// Hoisted mocks - these survive vi.clearAllMocks()
|
||||
const mockUninstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockLoadExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockGetExtensions = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock dependencies with hoisted functions
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
loadExtensions: mockLoadExtensions,
|
||||
getExtensions: mockGetExtensions,
|
||||
setRequestConsent: vi.fn(),
|
||||
setRequestSetting: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
error: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('error', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
promptForSetting: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions uninstall command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockGetErrorMessage = vi.mocked(getErrorMessage);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
|
||||
beforeEach(async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockLoadExtensions.mockClear();
|
||||
mockUninstallExtension.mockClear();
|
||||
mockGetExtensions.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleUninstall', () => {
|
||||
it('should uninstall a single extension', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ names: ['my-extension'] });
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(mockLoadExtensions).toHaveBeenCalled();
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'my-extension',
|
||||
false,
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "my-extension" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should uninstall multiple extensions', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext2" successfully uninstalled.',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext3" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should uninstall all extensions when --all flag is used', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ all: true });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext2" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log a message if no extensions are installed and --all flag is used', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ all: true });
|
||||
|
||||
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No extensions currently installed.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should report errors for failed uninstalls but continue with others', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
|
||||
const error = new Error('Extension not found');
|
||||
// Chain sequential mock behaviors - this works with hoisted mocks
|
||||
mockUninstallExtension
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockGetErrorMessage.mockReturnValue('Extension not found');
|
||||
|
||||
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to uninstall "ext2": Extension not found',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Extension "ext3" successfully uninstalled.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with error code if all uninstalls fail', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Extension not found');
|
||||
mockUninstallExtension.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Extension not found');
|
||||
|
||||
await handleUninstall({ names: ['ext1', 'ext2'] });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to uninstall "ext1": Extension not found',
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to uninstall "ext2": Extension not found',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when initialization fails', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Initialization failed');
|
||||
mockLoadExtensions.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Initialization failed message');
|
||||
|
||||
await handleUninstall({ names: ['my-extension'] });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Initialization failed message',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallCommand', () => {
|
||||
const command = uninstallCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('uninstall [names..]');
|
||||
expect(command.describe).toBe('Uninstalls one or more extensions.');
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
let yargsMock: MockYargs;
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure arguments and options', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('names', {
|
||||
describe:
|
||||
'The name(s) or source path(s) of the extension(s) to uninstall.',
|
||||
type: 'string',
|
||||
array: true,
|
||||
});
|
||||
expect(yargsMock.option).toHaveBeenCalledWith('all', {
|
||||
type: 'boolean',
|
||||
describe: 'Uninstall all installed extensions.',
|
||||
default: false,
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw for missing names and no --all flag', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ names: [], all: false })).toThrow(
|
||||
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
|
||||
);
|
||||
});
|
||||
|
||||
it('check function should pass if --all flag is used even without names', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ names: [], all: true })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleUninstall', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
names?: string[];
|
||||
all?: boolean;
|
||||
_: string[];
|
||||
$0: string;
|
||||
}
|
||||
const argv: TestArgv = {
|
||||
names: ['my-extension'],
|
||||
all: false,
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'my-extension',
|
||||
false,
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface UninstallArgs {
|
||||
names?: string[]; // can be extension names or source URLs.
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export async function handleUninstall(args: UninstallArgs) {
|
||||
try {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
let namesToUninstall: string[] = [];
|
||||
if (args.all) {
|
||||
namesToUninstall = extensionManager
|
||||
.getExtensions()
|
||||
.map((ext) => ext.name);
|
||||
} else if (args.names) {
|
||||
namesToUninstall = [...new Set(args.names)];
|
||||
}
|
||||
|
||||
if (namesToUninstall.length === 0) {
|
||||
if (args.all) {
|
||||
debugLogger.log('No extensions currently installed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
for (const name of namesToUninstall) {
|
||||
try {
|
||||
await extensionManager.uninstallExtension(name, false);
|
||||
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
|
||||
} catch (error) {
|
||||
errors.push({ name, error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const { name, error } of errors) {
|
||||
debugLogger.error(`Failed to uninstall "${name}": ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const uninstallCommand: CommandModule = {
|
||||
command: 'uninstall [names..]',
|
||||
describe: 'Uninstalls one or more extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('names', {
|
||||
describe:
|
||||
'The name(s) or source path(s) of the extension(s) to uninstall.',
|
||||
type: 'string',
|
||||
array: true,
|
||||
})
|
||||
.option('all', {
|
||||
type: 'boolean',
|
||||
describe: 'Uninstall all installed extensions.',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.all && (!argv.names || argv.names.length === 0)) {
|
||||
throw new Error(
|
||||
'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
names: argv['names'] as string[] | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
all: argv['all'] as boolean,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleUpdate, updateCommand } from './update.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import * as update from '../../config/extensions/update.js';
|
||||
import * as github from '../../config/extensions/github.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const emitFeedback = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
error: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('error', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitConsoleLog,
|
||||
emitFeedback,
|
||||
},
|
||||
debugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/update.js');
|
||||
vi.mock('../../config/extensions/github.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
promptForSetting: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions update command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
const mockUpdateExtension = vi.mocked(update.updateExtension);
|
||||
const mockCheckForExtensionUpdate = vi.mocked(github.checkForExtensionUpdate);
|
||||
const mockCheckForAllExtensionUpdates = vi.mocked(
|
||||
update.checkForAllExtensionUpdates,
|
||||
);
|
||||
const mockUpdateAllUpdatableExtensions = vi.mocked(
|
||||
update.updateAllUpdatableExtensions,
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: { experimental: { extensionReloading: true } },
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should list installed extensions when requested extension is not found', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
|
||||
await handleUpdate({ name: 'missing-extension' });
|
||||
|
||||
expect(emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Extension "missing-extension" not found.\n\nInstalled extensions:\next1 (1.0.0)\next2 (2.0.0)\n\nRun "gemini extensions list" for details.',
|
||||
);
|
||||
expect(mockUpdateExtension).not.toHaveBeenCalled();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log a helpful message when no extensions are installed and requested extension is not found', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await handleUpdate({ name: 'missing-extension' });
|
||||
|
||||
expect(emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Extension "missing-extension" not found.\n\nNo extensions installed.',
|
||||
);
|
||||
expect(mockUpdateExtension).not.toHaveBeenCalled();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expectedLog:
|
||||
'Extension "my-extension" successfully updated: 1.0.0 → 1.1.0.',
|
||||
shouldCallUpdateExtension: true,
|
||||
},
|
||||
{
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
expectedLog: 'Extension "my-extension" is already up to date.',
|
||||
shouldCallUpdateExtension: false,
|
||||
},
|
||||
])(
|
||||
'should handle single extension update state: $state',
|
||||
async ({ state, expectedLog, shouldCallUpdateExtension }) => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [{ name: 'my-extension', installMetadata: {} }];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
mockCheckForExtensionUpdate.mockResolvedValue(state);
|
||||
mockUpdateExtension.mockResolvedValue({
|
||||
name: 'my-extension',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
await handleUpdate({ name: 'my-extension' });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
|
||||
if (shouldCallUpdateExtension) {
|
||||
expect(mockUpdateExtension).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockUpdateExtension).not.toHaveBeenCalled();
|
||||
}
|
||||
mockCwd.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
updatedExtensions: [
|
||||
{ name: 'ext1', originalVersion: '1.0.0', updatedVersion: '1.1.0' },
|
||||
{ name: 'ext2', originalVersion: '2.0.0', updatedVersion: '2.1.0' },
|
||||
],
|
||||
expectedLog:
|
||||
'Extension "ext1" successfully updated: 1.0.0 → 1.1.0.\nExtension "ext2" successfully updated: 2.0.0 → 2.1.0.',
|
||||
},
|
||||
{
|
||||
updatedExtensions: [],
|
||||
expectedLog: 'No extensions to update.',
|
||||
},
|
||||
])(
|
||||
'should handle updating all extensions: %s',
|
||||
async ({ updatedExtensions, expectedLog }) => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue(updatedExtensions);
|
||||
|
||||
await handleUpdate({ all: true });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
|
||||
mockCwd.mockRestore();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('updateCommand', () => {
|
||||
const command = updateCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('update [<name>] [--all]');
|
||||
expect(command.describe).toBe(
|
||||
'Updates all extensions or a named extension to the latest version.',
|
||||
);
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
interface MockYargs {
|
||||
positional: Mock;
|
||||
option: Mock;
|
||||
conflicts: Mock;
|
||||
check: Mock;
|
||||
}
|
||||
|
||||
let yargsMock: MockYargs;
|
||||
beforeEach(() => {
|
||||
yargsMock = {
|
||||
positional: vi.fn().mockReturnThis(),
|
||||
option: vi.fn().mockReturnThis(),
|
||||
conflicts: vi.fn().mockReturnThis(),
|
||||
check: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should configure arguments', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith(
|
||||
'name',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(yargsMock.option).toHaveBeenCalledWith(
|
||||
'all',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(yargsMock.conflicts).toHaveBeenCalledWith('name', 'all');
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw an error if neither a name nor --all is provided', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ name: undefined, all: false })).toThrow(
|
||||
'Either an extension name or --all must be provided',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleUpdate', async () => {
|
||||
const extensions = [{ name: 'my-extension', installMetadata: {} }];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
mockCheckForExtensionUpdate.mockResolvedValue(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
mockUpdateExtension.mockResolvedValue({
|
||||
name: 'my-extension',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.1.0',
|
||||
});
|
||||
|
||||
await (command.handler as (args: object) => Promise<void>)({
|
||||
name: 'my-extension',
|
||||
});
|
||||
|
||||
expect(mockUpdateExtension).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
updateAllUpdatableExtensions,
|
||||
type ExtensionUpdateInfo,
|
||||
checkForAllExtensionUpdates,
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface UpdateArgs {
|
||||
name?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
const updateOutput = (info: ExtensionUpdateInfo) =>
|
||||
`Extension "${info.name}" successfully updated: ${info.originalVersion} → ${info.updatedVersion}.`;
|
||||
|
||||
export async function handleUpdate(args: UpdateArgs) {
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
if (args.name) {
|
||||
try {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === args.name,
|
||||
);
|
||||
if (!extension) {
|
||||
if (extensions.length === 0) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Extension "${args.name}" not found.\n\nNo extensions installed.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const installedExtensions = extensions
|
||||
.map((extension) => `${extension.name} (${extension.version})`)
|
||||
.join('\n');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Extension "${args.name}" not found.\n\nInstalled extensions:\n${installedExtensions}\n\nRun "gemini extensions list" for details.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!extension.installMetadata) {
|
||||
debugLogger.log(
|
||||
`Unable to install extension "${args.name}" due to missing install metadata`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updateState = await checkForExtensionUpdate(
|
||||
extension,
|
||||
extensionManager,
|
||||
);
|
||||
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
|
||||
debugLogger.log(`Extension "${args.name}" is already up to date.`);
|
||||
return;
|
||||
}
|
||||
const updatedExtensionInfo = (await updateExtension(
|
||||
extension,
|
||||
extensionManager,
|
||||
updateState,
|
||||
() => {},
|
||||
settings.experimental?.extensionReloading,
|
||||
))!;
|
||||
if (
|
||||
updatedExtensionInfo.originalVersion !==
|
||||
updatedExtensionInfo.updatedVersion
|
||||
) {
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(`Extension "${args.name}" is already up to date.`);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
if (args.all) {
|
||||
try {
|
||||
const extensionState = new Map();
|
||||
await checkForAllExtensionUpdates(
|
||||
extensions,
|
||||
extensionManager,
|
||||
(action) => {
|
||||
if (action.type === 'SET_STATE') {
|
||||
extensionState.set(action.payload.name, {
|
||||
status: action.payload.state,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
let updateInfos = await updateAllUpdatableExtensions(
|
||||
extensions,
|
||||
extensionState,
|
||||
extensionManager,
|
||||
() => {},
|
||||
);
|
||||
updateInfos = updateInfos.filter(
|
||||
(info) => info.originalVersion !== info.updatedVersion,
|
||||
);
|
||||
if (updateInfos.length === 0) {
|
||||
debugLogger.log('No extensions to update.');
|
||||
return;
|
||||
}
|
||||
debugLogger.log(updateInfos.map((info) => updateOutput(info)).join('\n'));
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateCommand: CommandModule = {
|
||||
command: 'update [<name>] [--all]',
|
||||
describe:
|
||||
'Updates all extensions or a named extension to the latest version.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the extension to update.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('all', {
|
||||
describe: 'Update all extensions.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.conflicts('name', 'all')
|
||||
.check((argv) => {
|
||||
if (!argv.all && !argv.name) {
|
||||
throw new Error('Either an extension name or --all must be provided');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUpdate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
all: argv['all'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import {
|
||||
debugLogger,
|
||||
type ResolvedExtensionSetting,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import prompts from 'prompts';
|
||||
import {
|
||||
promptForSetting,
|
||||
updateSetting,
|
||||
type ExtensionSetting,
|
||||
getScopedEnvContents,
|
||||
ExtensionSettingScope,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
export interface ConfigLogger {
|
||||
log(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export type RequestSettingCallback = (
|
||||
setting: ExtensionSetting,
|
||||
) => Promise<string | undefined>;
|
||||
export type RequestConfirmationCallback = (message: string) => Promise<boolean>;
|
||||
|
||||
const defaultLogger: ConfigLogger = {
|
||||
log: (message: string) => debugLogger.log(message),
|
||||
error: (message: string) => debugLogger.error(message),
|
||||
};
|
||||
|
||||
const defaultRequestSetting: RequestSettingCallback = async (setting) =>
|
||||
promptForSetting(setting);
|
||||
|
||||
const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
) => {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
return typeof response.confirm === 'boolean' ? response.confirm : false;
|
||||
};
|
||||
|
||||
export async function getExtensionManager() {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
export async function getExtensionAndManager(
|
||||
extensionManager: ExtensionManager,
|
||||
name: string,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
) {
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.name === name);
|
||||
|
||||
if (!extension) {
|
||||
logger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null };
|
||||
}
|
||||
|
||||
return { extension };
|
||||
}
|
||||
|
||||
export async function configureSpecificSetting(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
settingKey: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
logger.error(
|
||||
`Could not find configuration for extension "${extensionName}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
settingKey,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
logger.log(`Setting "${settingKey}" updated.`);
|
||||
}
|
||||
|
||||
export async function configureExtension(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
logger.log(`Extension "${extensionName}" has no settings to configure.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Configuring settings for "${extensionName}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureAllExtensions(
|
||||
extensionManager: ExtensionManager,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const extensions = extensionManager.getExtensions();
|
||||
|
||||
if (extensions.length === 0) {
|
||||
logger.log('No extensions installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
extensionConfig &&
|
||||
extensionConfig.settings &&
|
||||
extensionConfig.settings.length > 0
|
||||
) {
|
||||
logger.log(`\nConfiguring settings for "${extension.name}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureExtensionSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const currentScopedSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!extensionConfig.settings) return;
|
||||
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const currentValue = currentScopedSettings[setting.envVar];
|
||||
const workspaceValue = workspaceSettings[setting.envVar];
|
||||
|
||||
if (workspaceValue !== undefined) {
|
||||
logger.log(
|
||||
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentValue !== undefined) {
|
||||
const confirmed = await requestConfirmation(
|
||||
`Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
setting.envVar,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getFormattedSettingValue(
|
||||
setting: ResolvedExtensionSetting,
|
||||
): string {
|
||||
if (!setting.value) {
|
||||
return '[not set]';
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
return '***';
|
||||
}
|
||||
return setting.value;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { handleValidate, validateCommand } from './validate.js';
|
||||
import yargs from 'yargs';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
import path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('extensions validate command', () => {
|
||||
it('should fail if no path is provided', () => {
|
||||
const validationParser = yargs([]).command(validateCommand).fail(false);
|
||||
expect(() => validationParser.parse('validate')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleValidate', () => {
|
||||
let debugLoggerLogSpy: MockInstance;
|
||||
let debugLoggerWarnSpy: MockInstance;
|
||||
let debugLoggerErrorSpy: MockInstance;
|
||||
let processSpy: MockInstance;
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
|
||||
debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn');
|
||||
debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home'));
|
||||
tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace'));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should validate an extension from a local dir', async () => {
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'local-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await handleValidate({
|
||||
path: 'local-ext-name',
|
||||
});
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Extension local-ext-name has been successfully validated.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the extension name is invalid', async () => {
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'INVALID_NAME',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await handleValidate({
|
||||
path: 'INVALID_NAME',
|
||||
});
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.',
|
||||
),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should warn if version is not formatted with semver', async () => {
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'valid-name',
|
||||
version: '1',
|
||||
});
|
||||
|
||||
await handleValidate({
|
||||
path: 'valid-name',
|
||||
});
|
||||
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Version '1' does not appear to be standard semver (e.g., 1.0.0).",
|
||||
),
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Extension valid-name has been successfully validated.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if context files are missing', async () => {
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'valid-name',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'contextFile.md',
|
||||
});
|
||||
fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md'));
|
||||
await handleValidate({
|
||||
path: 'valid-name',
|
||||
});
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'The following context files referenced in gemini-extension.json are missing: contextFile.md',
|
||||
),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface ValidateArgs {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function handleValidate(args: ValidateArgs) {
|
||||
try {
|
||||
await validateExtension(args);
|
||||
debugLogger.log(`Extension ${args.path} has been successfully validated.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExtension(args: ValidateArgs) {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
const absoluteInputPath = path.resolve(args.path);
|
||||
const extensionConfig: ExtensionConfig =
|
||||
await extensionManager.loadExtensionConfig(absoluteInputPath);
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (extensionConfig.contextFileName) {
|
||||
const contextFileNames = Array.isArray(extensionConfig.contextFileName)
|
||||
? extensionConfig.contextFileName
|
||||
: [extensionConfig.contextFileName];
|
||||
|
||||
const missingContextFiles: string[] = [];
|
||||
for (const contextFilePath of contextFileNames) {
|
||||
const contextFileAbsolutePath = path.resolve(
|
||||
absoluteInputPath,
|
||||
contextFilePath,
|
||||
);
|
||||
if (!fs.existsSync(contextFileAbsolutePath)) {
|
||||
missingContextFiles.push(contextFilePath);
|
||||
}
|
||||
}
|
||||
if (missingContextFiles.length > 0) {
|
||||
errors.push(
|
||||
`The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!semver.valid(extensionConfig.version)) {
|
||||
warnings.push(
|
||||
`Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
debugLogger.warn('Validation warnings:');
|
||||
for (const warning of warnings) {
|
||||
debugLogger.warn(` - ${warning}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
debugLogger.error('Validation failed with the following errors:');
|
||||
for (const error of errors) {
|
||||
debugLogger.error(` - ${error}`);
|
||||
}
|
||||
throw new Error('Extension validation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
export const validateCommand: CommandModule = {
|
||||
command: 'validate <path>',
|
||||
describe: 'Validates an extension from a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('path', {
|
||||
describe: 'The path of the extension to validate.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
await handleValidate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
import { setupCommand } from './gemma/setup.js';
|
||||
import { startCommand } from './gemma/start.js';
|
||||
import { stopCommand } from './gemma/stop.js';
|
||||
import { statusCommand } from './gemma/status.js';
|
||||
import { logsCommand } from './gemma/logs.js';
|
||||
|
||||
export const gemmaCommand: CommandModule = {
|
||||
command: 'gemma',
|
||||
describe: 'Manage local Gemma model routing',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(setupCommand, 'gemma'))
|
||||
.command(defer(startCommand, 'gemma'))
|
||||
.command(defer(stopCommand, 'gemma'))
|
||||
.command(defer(statusCommand, 'gemma'))
|
||||
.command(defer(logsCommand, 'gemma'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { Storage } from '@google/gemini-cli-core';
|
||||
|
||||
export const LITERT_RELEASE_VERSION = 'v0.9.0-alpha03';
|
||||
export const LITERT_RELEASE_BASE_URL =
|
||||
'https://github.com/google-ai-edge/LiteRT-LM/releases/download';
|
||||
export const GEMMA_MODEL_NAME = 'gemma3-1b-gpu-custom';
|
||||
export const DEFAULT_PORT = 9379;
|
||||
export const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
export const LITERT_API_VERSION = 'v1beta';
|
||||
export const SERVER_START_WAIT_MS = 3000;
|
||||
|
||||
export const PLATFORM_BINARY_MAP: Record<string, string> = {
|
||||
'darwin-arm64': 'lit.macos_arm64',
|
||||
'linux-x64': 'lit.linux_x86_64',
|
||||
'win32-x64': 'lit.windows_x86_64.exe',
|
||||
};
|
||||
|
||||
// SHA-256 hashes for the official LiteRT-LM v0.9.0-alpha03 release binaries.
|
||||
export const PLATFORM_BINARY_SHA256: Record<string, string> = {
|
||||
'lit.macos_arm64':
|
||||
'9e826a2634f2e8b220ad0f1e1b5c139e0b47cb172326e3b7d46d31382f49478e',
|
||||
'lit.linux_x86_64':
|
||||
'66601df8a07f08244b188e9fcab0bf4a16562fe76d8d47e49f40273d57541ee8',
|
||||
'lit.windows_x86_64.exe':
|
||||
'de82d2829d2fb1cbdb318e2d8a78dc2f9659ff14cb11b2894d1f30e0bfde2bf6',
|
||||
};
|
||||
|
||||
export function getLiteRtBinDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'bin', 'litert');
|
||||
}
|
||||
|
||||
export function getPidFilePath(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'litert-server.pid');
|
||||
}
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'litert-server.log');
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getLogFilePath } from './constants.js';
|
||||
import { logsCommand, readLastLines } from './logs.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./constants.js', () => ({
|
||||
getLogFilePath: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMockChild(): ChildProcess {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('readLastLines', () => {
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempFiles
|
||||
.splice(0)
|
||||
.map((filePath) => fs.promises.rm(filePath, { force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only the requested tail lines without reading the whole file eagerly', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
|
||||
const content = Array.from({ length: 2000 }, (_, i) => `line-${i + 1}`)
|
||||
.join('\n')
|
||||
.concat('\n');
|
||||
await fs.promises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
await expect(readLastLines(filePath, 3)).resolves.toBe(
|
||||
'line-1998\nline-1999\nline-2000\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty string when zero lines are requested', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'line-1\nline-2\n', 'utf-8');
|
||||
|
||||
await expect(readLastLines(filePath, 0)).resolves.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logsCommand', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(getLogFilePath).mockReturnValue('/tmp/gemma.log');
|
||||
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('waits for the tail process to close before exiting in follow mode', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
let resolved = false;
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({}).then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '20', '/tmp/gemma.log'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
expect(resolved).toBe(false);
|
||||
expect(exitCli).not.toHaveBeenCalled();
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('uses one-shot tail output when follow is disabled', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({ follow: false });
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith('tail', ['-n', '20', '/tmp/gemma.log'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('follows from the requested line count when both --lines and --follow are set', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({ lines: 5, follow: true });
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '5', '/tmp/gemma.log'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getLogFilePath } from './constants.js';
|
||||
|
||||
export async function readLastLines(
|
||||
filePath: string,
|
||||
count: number,
|
||||
): Promise<string> {
|
||||
if (count <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
const fileHandle = await fs.promises.open(filePath, fs.constants.O_RDONLY);
|
||||
|
||||
try {
|
||||
const stats = await fileHandle.stat();
|
||||
if (stats.size === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
let newlineCount = 0;
|
||||
let position = stats.size;
|
||||
|
||||
while (position > 0 && newlineCount <= count) {
|
||||
const readSize = Math.min(CHUNK_SIZE, position);
|
||||
position -= readSize;
|
||||
|
||||
const buffer = Buffer.allocUnsafe(readSize);
|
||||
const { bytesRead } = await fileHandle.read(
|
||||
buffer,
|
||||
0,
|
||||
readSize,
|
||||
position,
|
||||
);
|
||||
|
||||
if (bytesRead === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk =
|
||||
bytesRead === readSize ? buffer : buffer.subarray(0, bytesRead);
|
||||
chunks.unshift(chunk);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
for (const byte of chunk) {
|
||||
if (byte === 0x0a) {
|
||||
newlineCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = Buffer.concat(chunks, totalBytes).toString('utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (position > 0 && lines.length > 0) {
|
||||
const boundary = Buffer.allocUnsafe(1);
|
||||
const { bytesRead } = await fileHandle.read(boundary, 0, 1, position - 1);
|
||||
if (bytesRead === 1 && boundary[0] !== 0x0a) {
|
||||
lines.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return lines.slice(-count).join('\n') + '\n';
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface LogsArgs {
|
||||
lines?: number;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
function waitForChild(child: ChildProcess): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
async function runTail(logPath: string, lines: number, follow: boolean) {
|
||||
const tailArgs = follow
|
||||
? ['-f', '-n', String(lines), logPath]
|
||||
: ['-n', String(lines), logPath];
|
||||
const child = spawn('tail', tailArgs, { stdio: 'inherit' });
|
||||
|
||||
if (!follow) {
|
||||
return waitForChild(child);
|
||||
}
|
||||
|
||||
const handleSigint = () => {
|
||||
child.kill('SIGTERM');
|
||||
};
|
||||
process.once('SIGINT', handleSigint);
|
||||
|
||||
try {
|
||||
return await waitForChild(child);
|
||||
} finally {
|
||||
process.off('SIGINT', handleSigint);
|
||||
}
|
||||
}
|
||||
|
||||
export const logsCommand: CommandModule<object, LogsArgs> = {
|
||||
command: 'logs',
|
||||
describe: 'View LiteRT-LM server logs',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('lines', {
|
||||
alias: 'n',
|
||||
type: 'number',
|
||||
description: 'Show the last N lines and exit (omit to follow live)',
|
||||
})
|
||||
.option('follow', {
|
||||
alias: 'f',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Follow log output (defaults to true when --lines is omitted)',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const logPath = getLogFilePath();
|
||||
|
||||
try {
|
||||
await fs.promises.access(logPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
debugLogger.log(`No log file found at ${logPath}`);
|
||||
debugLogger.log(
|
||||
'Is the LiteRT server running? Start it with: gemini gemma start',
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = argv.lines;
|
||||
const follow = argv.follow ?? lines === undefined;
|
||||
const requestedLines = lines ?? 20;
|
||||
|
||||
if (follow && process.platform === 'win32') {
|
||||
debugLogger.log(
|
||||
'Live log following is not supported on Windows. Use --lines N to view recent logs.',
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
process.stdout.write(await readLastLines(logPath, requestedLines));
|
||||
await exitCli(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (follow) {
|
||||
debugLogger.log(`Tailing ${logPath} (Ctrl+C to stop)\n`);
|
||||
}
|
||||
const exitCode = await runTail(logPath, requestedLines, follow);
|
||||
await exitCli(exitCode);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
if (!follow) {
|
||||
process.stdout.write(await readLastLines(logPath, requestedLines));
|
||||
await exitCli(0);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
'"tail" command not found. Use --lines N to view recent logs without tail.',
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
} else {
|
||||
debugLogger.error(
|
||||
`Failed to read log output: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getLiteRtBinDir } from './constants.js';
|
||||
|
||||
const mockLoadSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: mockLoadSettings,
|
||||
SettingScope: {
|
||||
User: 'User',
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getBinaryPath,
|
||||
isExpectedLiteRtServerCommand,
|
||||
isBinaryInstalled,
|
||||
readServerProcessInfo,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
|
||||
describe('gemma platform helpers', () => {
|
||||
function createMockSettings(
|
||||
userGemmaSettings?: object,
|
||||
mergedGemmaSettings?: object,
|
||||
) {
|
||||
return {
|
||||
merged: {
|
||||
experimental: {
|
||||
gemmaModelRouter: mergedGemmaSettings,
|
||||
},
|
||||
},
|
||||
forScope: vi.fn((scope: SettingScope) => {
|
||||
if (scope !== SettingScope.User) {
|
||||
throw new Error(`Unexpected scope ${scope}`);
|
||||
}
|
||||
return {
|
||||
settings: {
|
||||
experimental: {
|
||||
gemmaModelRouter: userGemmaSettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue(createMockSettings());
|
||||
});
|
||||
|
||||
it('prefers the configured binary path from settings', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings({ binaryPath: '/custom/lit' }),
|
||||
);
|
||||
|
||||
expect(getBinaryPath('lit.test')).toBe('/custom/lit');
|
||||
});
|
||||
|
||||
it('ignores workspace overrides for the configured binary path', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings(
|
||||
{ binaryPath: '/user/lit' },
|
||||
{ binaryPath: '/workspace/evil' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(getBinaryPath('lit.test')).toBe('/user/lit');
|
||||
});
|
||||
|
||||
it('falls back to the default install location when no custom path is set', () => {
|
||||
expect(getBinaryPath('lit.test')).toBe(
|
||||
path.join(getLiteRtBinDir(), 'lit.test'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the configured port and binary path from settings', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings(
|
||||
{ binaryPath: '/custom/lit' },
|
||||
{
|
||||
enabled: true,
|
||||
classifier: {
|
||||
host: 'http://localhost:8123/v1beta',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(resolveGemmaConfig(9379)).toEqual({
|
||||
settingsEnabled: true,
|
||||
configuredPort: 8123,
|
||||
configuredBinaryPath: '/custom/lit',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks binary installation using the resolved binary path', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings({ binaryPath: '/custom/lit' }),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
expect(isBinaryInstalled()).toBe(true);
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/custom/lit');
|
||||
});
|
||||
|
||||
it('parses structured server process info from the pid file', () => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readServerProcessInfo()).toEqual({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses legacy pid-only files for backward compatibility', () => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('4321');
|
||||
|
||||
expect(readServerProcessInfo()).toEqual({
|
||||
pid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches only the expected LiteRT serve command', () => {
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit serve --port=8123 --verbose', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit run --port=8123', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit serve --port=9000', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
PLATFORM_BINARY_MAP,
|
||||
LITERT_RELEASE_BASE_URL,
|
||||
LITERT_RELEASE_VERSION,
|
||||
getLiteRtBinDir,
|
||||
GEMMA_MODEL_NAME,
|
||||
HEALTH_CHECK_TIMEOUT_MS,
|
||||
LITERT_API_VERSION,
|
||||
getPidFilePath,
|
||||
} from './constants.js';
|
||||
|
||||
export interface PlatformInfo {
|
||||
key: string;
|
||||
binaryName: string;
|
||||
}
|
||||
|
||||
export interface GemmaConfigStatus {
|
||||
settingsEnabled: boolean;
|
||||
configuredPort: number;
|
||||
configuredBinaryPath?: string;
|
||||
}
|
||||
|
||||
export interface LiteRtServerProcessInfo {
|
||||
pid: number;
|
||||
binaryPath?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
function getUserConfiguredBinaryPath(
|
||||
workspaceDir = process.cwd(),
|
||||
): string | undefined {
|
||||
try {
|
||||
const userGemmaSettings = loadSettings(workspaceDir).forScope(
|
||||
SettingScope.User,
|
||||
).settings.experimental?.gemmaModelRouter;
|
||||
return userGemmaSettings?.binaryPath?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePortFromHost(
|
||||
host: string | undefined,
|
||||
fallbackPort: number,
|
||||
): number {
|
||||
if (!host) {
|
||||
return fallbackPort;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(host);
|
||||
const port = Number(url.port);
|
||||
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
|
||||
} catch {
|
||||
const match = host.match(/:(\d+)/);
|
||||
if (!match) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = parseInt(match[1], 10);
|
||||
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGemmaConfig(fallbackPort: number): GemmaConfigStatus {
|
||||
let settingsEnabled = false;
|
||||
let configuredPort = fallbackPort;
|
||||
const configuredBinaryPath = getUserConfiguredBinaryPath();
|
||||
try {
|
||||
const settings = loadSettings(process.cwd());
|
||||
const gemmaSettings = settings.merged.experimental?.gemmaModelRouter;
|
||||
settingsEnabled = gemmaSettings?.enabled === true;
|
||||
configuredPort = parsePortFromHost(
|
||||
gemmaSettings?.classifier?.host,
|
||||
fallbackPort,
|
||||
);
|
||||
} catch {
|
||||
// ignore — settings may fail to load outside a workspace
|
||||
}
|
||||
return { settingsEnabled, configuredPort, configuredBinaryPath };
|
||||
}
|
||||
|
||||
export function detectPlatform(): PlatformInfo | null {
|
||||
const key = `${process.platform}-${process.arch}`;
|
||||
const binaryName = PLATFORM_BINARY_MAP[key];
|
||||
if (!binaryName) {
|
||||
return null;
|
||||
}
|
||||
return { key, binaryName };
|
||||
}
|
||||
|
||||
export function getBinaryPath(binaryName?: string): string | null {
|
||||
const configuredBinaryPath = getUserConfiguredBinaryPath();
|
||||
if (configuredBinaryPath) {
|
||||
return configuredBinaryPath;
|
||||
}
|
||||
|
||||
const name = binaryName ?? detectPlatform()?.binaryName;
|
||||
if (!name) return null;
|
||||
return path.join(getLiteRtBinDir(), name);
|
||||
}
|
||||
|
||||
export function getBinaryDownloadUrl(binaryName: string): string {
|
||||
return `${LITERT_RELEASE_BASE_URL}/${LITERT_RELEASE_VERSION}/${binaryName}`;
|
||||
}
|
||||
|
||||
export function isBinaryInstalled(binaryPath = getBinaryPath()): boolean {
|
||||
if (!binaryPath) return false;
|
||||
return fs.existsSync(binaryPath);
|
||||
}
|
||||
|
||||
export function isModelDownloaded(binaryPath: string): boolean {
|
||||
try {
|
||||
const output = execFileSync(binaryPath, ['list'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
return output.includes(GEMMA_MODEL_NAME);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isServerRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
HEALTH_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://localhost:${port}/${LITERT_API_VERSION}/models/${GEMMA_MODEL_NAME}:generateContent`,
|
||||
{ method: 'POST', signal: controller.signal },
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
// A 400 (bad request) confirms the route exists — the server recognises
|
||||
// the model endpoint. Only a 404 means "wrong server / wrong model".
|
||||
return response.status !== 404;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLiteRtServerProcessInfo(
|
||||
value: unknown,
|
||||
): value is LiteRtServerProcessInfo {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPositiveInteger = (candidate: unknown): candidate is number =>
|
||||
typeof candidate === 'number' &&
|
||||
Number.isInteger(candidate) &&
|
||||
candidate > 0;
|
||||
const isNonEmptyString = (candidate: unknown): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.length > 0;
|
||||
|
||||
const pid: unknown = Object.getOwnPropertyDescriptor(value, 'pid')?.value;
|
||||
if (!isPositiveInteger(pid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const binaryPath: unknown = Object.getOwnPropertyDescriptor(
|
||||
value,
|
||||
'binaryPath',
|
||||
)?.value;
|
||||
if (binaryPath !== undefined && !isNonEmptyString(binaryPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const port: unknown = Object.getOwnPropertyDescriptor(value, 'port')?.value;
|
||||
if (port !== undefined && !isPositiveInteger(port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readServerProcessInfo(): LiteRtServerProcessInfo | null {
|
||||
const pidPath = getPidFilePath();
|
||||
try {
|
||||
const content = fs.readFileSync(pidPath, 'utf-8').trim();
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(content)) {
|
||||
return { pid: parseInt(content, 10) };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return isLiteRtServerProcessInfo(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerProcessInfo(
|
||||
processInfo: LiteRtServerProcessInfo,
|
||||
): void {
|
||||
fs.writeFileSync(getPidFilePath(), JSON.stringify(processInfo), 'utf-8');
|
||||
}
|
||||
|
||||
export function readServerPid(): number | null {
|
||||
return readServerProcessInfo()?.pid ?? null;
|
||||
}
|
||||
|
||||
function normalizeProcessValue(value: string): string {
|
||||
const normalized = value.replace(/\0/g, ' ').trim();
|
||||
if (process.platform === 'win32') {
|
||||
return normalized.replace(/\\/g, '/').replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
return normalized.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function readProcessCommandLine(pid: number): string | null {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const output = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
|
||||
return output.trim() ? output : null;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
return output.trim() || null;
|
||||
}
|
||||
|
||||
const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedLiteRtServerCommand(
|
||||
commandLine: string,
|
||||
options: {
|
||||
binaryPath?: string | null;
|
||||
port?: number;
|
||||
},
|
||||
): boolean {
|
||||
const normalizedCommandLine = normalizeProcessValue(commandLine);
|
||||
if (!normalizedCommandLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/(^|\s|")serve(\s|$)/.test(normalizedCommandLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.port !== undefined &&
|
||||
!normalizedCommandLine.includes(`--port=${options.port}`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!options.binaryPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedBinaryPath = normalizeProcessValue(options.binaryPath);
|
||||
const normalizedBinaryName = normalizeProcessValue(
|
||||
path.basename(options.binaryPath),
|
||||
);
|
||||
return (
|
||||
normalizedCommandLine.includes(normalizedBinaryPath) ||
|
||||
normalizedCommandLine.includes(normalizedBinaryName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isExpectedLiteRtServerProcess(
|
||||
pid: number,
|
||||
options: {
|
||||
binaryPath?: string | null;
|
||||
port?: number;
|
||||
},
|
||||
): boolean {
|
||||
const commandLine = readProcessCommandLine(pid);
|
||||
if (!commandLine) {
|
||||
return false;
|
||||
}
|
||||
return isExpectedLiteRtServerCommand(commandLine, options);
|
||||
}
|
||||
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_BINARY_MAP, PLATFORM_BINARY_SHA256 } from './constants.js';
|
||||
import { computeFileSha256, verifyFileSha256 } from './setup.js';
|
||||
|
||||
describe('gemma setup checksum helpers', () => {
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempFiles
|
||||
.splice(0)
|
||||
.map((filePath) => fs.promises.rm(filePath, { force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it('has a pinned checksum for every supported LiteRT binary', () => {
|
||||
expect(Object.keys(PLATFORM_BINARY_SHA256).sort()).toEqual(
|
||||
Object.values(PLATFORM_BINARY_MAP).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes the sha256 for a downloaded file', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
|
||||
|
||||
await expect(computeFileSha256(filePath)).resolves.toBe(
|
||||
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies whether a file matches the expected sha256', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
|
||||
|
||||
await expect(
|
||||
verifyFileSha256(
|
||||
filePath,
|
||||
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(verifyFileSha256(filePath, 'deadbeef')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
GEMMA_MODEL_NAME,
|
||||
PLATFORM_BINARY_SHA256,
|
||||
} from './constants.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
getBinaryDownloadUrl,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isModelDownloaded,
|
||||
} from './platform.js';
|
||||
import { startServer } from './start.js';
|
||||
import readline from 'node:readline';
|
||||
|
||||
const log = (msg: string) => debugLogger.log(msg);
|
||||
const logError = (msg: string) => debugLogger.error(msg);
|
||||
|
||||
async function promptYesNo(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (y/N): `, (answer) => {
|
||||
rl.close();
|
||||
resolve(
|
||||
answer.trim().toLowerCase() === 'y' ||
|
||||
answer.trim().toLowerCase() === 'yes',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function renderProgress(downloaded: number, total: number | null): void {
|
||||
const barWidth = 30;
|
||||
if (total && total > 0) {
|
||||
const pct = Math.min(downloaded / total, 1);
|
||||
const filled = Math.round(barWidth * pct);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const pctStr = (pct * 100).toFixed(0).padStart(3);
|
||||
process.stderr.write(
|
||||
`\r [${bar}] ${pctStr}% ${formatBytes(downloaded)} / ${formatBytes(total)}`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`\r Downloaded ${formatBytes(downloaded)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
const tmpPath = destPath + '.downloading';
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { redirect: 'follow' });
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Download failed: HTTP ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('Download failed: No response body');
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
|
||||
let downloadedBytes = 0;
|
||||
|
||||
const fileStream = fs.createWriteStream(tmpPath);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const writeOk = fileStream.write(value);
|
||||
if (!writeOk) {
|
||||
await new Promise<void>((resolve) => fileStream.once('drain', resolve));
|
||||
}
|
||||
downloadedBytes += value.byteLength;
|
||||
renderProgress(downloadedBytes, totalBytes);
|
||||
}
|
||||
} finally {
|
||||
fileStream.end();
|
||||
process.stderr.write('\r' + ' '.repeat(80) + '\r');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.on('finish', resolve);
|
||||
fileStream.on('error', reject);
|
||||
});
|
||||
|
||||
fs.renameSync(tmpPath, destPath);
|
||||
}
|
||||
|
||||
export async function computeFileSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fileStream.on('data', (chunk) => {
|
||||
hash.update(chunk);
|
||||
});
|
||||
fileStream.on('error', reject);
|
||||
fileStream.on('end', () => {
|
||||
resolve(hash.digest('hex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyFileSha256(
|
||||
filePath: string,
|
||||
expectedHash: string,
|
||||
): Promise<boolean> {
|
||||
const actualHash = await computeFileSha256(filePath);
|
||||
return actualHash === expectedHash;
|
||||
}
|
||||
|
||||
function spawnInherited(command: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = nodeSpawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
interface SetupArgs {
|
||||
port: number;
|
||||
skipModel: boolean;
|
||||
start: boolean;
|
||||
force: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
async function handleSetup(argv: SetupArgs): Promise<number> {
|
||||
const { port, force } = argv;
|
||||
let settingsUpdated = false;
|
||||
let serverStarted = false;
|
||||
let autoStartServer = true;
|
||||
|
||||
log('');
|
||||
log(chalk.bold('Gemma Local Model Routing Setup'));
|
||||
log(chalk.dim('─'.repeat(40)));
|
||||
log('');
|
||||
|
||||
const platform = detectPlatform();
|
||||
if (!platform) {
|
||||
logError(
|
||||
chalk.red(`Unsupported platform: ${process.platform}-${process.arch}`),
|
||||
);
|
||||
logError(
|
||||
'LiteRT-LM binaries are available for: macOS (ARM64), Linux (x86_64), Windows (x86_64)',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
log(chalk.dim(` Platform: ${platform.key} → ${platform.binaryName}`));
|
||||
|
||||
if (!argv.consent) {
|
||||
log('');
|
||||
log('This will download and install the LiteRT-LM runtime and the');
|
||||
log(
|
||||
`Gemma model (${GEMMA_MODEL_NAME}, ~1 GB). By proceeding, you agree to the`,
|
||||
);
|
||||
log('Gemma Terms of Use: https://ai.google.dev/gemma/terms');
|
||||
log('');
|
||||
|
||||
const accepted = await promptYesNo('Do you want to continue?');
|
||||
if (!accepted) {
|
||||
log('Setup cancelled.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath(platform.binaryName)!;
|
||||
const alreadyInstalled = isBinaryInstalled();
|
||||
|
||||
if (alreadyInstalled && !force) {
|
||||
log('');
|
||||
log(chalk.green(' ✓ LiteRT-LM binary already installed at:'));
|
||||
log(chalk.dim(` ${binaryPath}`));
|
||||
} else {
|
||||
log('');
|
||||
log(' Downloading LiteRT-LM binary...');
|
||||
const downloadUrl = getBinaryDownloadUrl(platform.binaryName);
|
||||
debugLogger.log(`Downloading from: ${downloadUrl}`);
|
||||
|
||||
try {
|
||||
const binDir = path.dirname(binaryPath);
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
await downloadFile(downloadUrl, binaryPath);
|
||||
log(chalk.green(' ✓ Binary downloaded successfully'));
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to download binary: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
logError(' Check your internet connection and try again.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const expectedHash = PLATFORM_BINARY_SHA256[platform.binaryName];
|
||||
if (!expectedHash) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ No checksum is configured for ${platform.binaryName}. Refusing to install the binary.`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const checksumVerified = await verifyFileSha256(binaryPath, expectedHash);
|
||||
if (!checksumVerified) {
|
||||
logError(
|
||||
chalk.red(
|
||||
' ✗ Downloaded binary checksum did not match the expected release hash.',
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
log(chalk.green(' ✓ Binary checksum verified'));
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to verify binary checksum: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to set executable permission: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
execFileSync('xattr', ['-d', 'com.apple.quarantine', binaryPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
log(chalk.green(' ✓ macOS quarantine attribute removed'));
|
||||
} catch {
|
||||
// Expected if the attribute doesn't exist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!argv.skipModel) {
|
||||
const modelAlreadyDownloaded = isModelDownloaded(binaryPath);
|
||||
if (modelAlreadyDownloaded && !force) {
|
||||
log('');
|
||||
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} already downloaded`));
|
||||
} else {
|
||||
log('');
|
||||
log(` Downloading model ${GEMMA_MODEL_NAME}...`);
|
||||
log(chalk.dim(' You may be prompted to accept the Gemma Terms of Use.'));
|
||||
log('');
|
||||
|
||||
const exitCode = await spawnInherited(binaryPath, [
|
||||
'pull',
|
||||
GEMMA_MODEL_NAME,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
logError('');
|
||||
logError(
|
||||
chalk.red(` ✗ Model download failed (exit code ${exitCode})`),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
log('');
|
||||
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} downloaded`));
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
log(' Configuring settings...');
|
||||
try {
|
||||
const settings = loadSettings(process.cwd());
|
||||
|
||||
// User scope: security-sensitive settings that must not be overridable
|
||||
// by workspace configs (prevents arbitrary binary execution).
|
||||
const existingUserGemma =
|
||||
settings.forScope(SettingScope.User).settings.experimental
|
||||
?.gemmaModelRouter ?? {};
|
||||
autoStartServer = existingUserGemma.autoStartServer ?? true;
|
||||
const existingUserExperimental =
|
||||
settings.forScope(SettingScope.User).settings.experimental ?? {};
|
||||
settings.setValue(SettingScope.User, 'experimental', {
|
||||
...existingUserExperimental,
|
||||
gemmaModelRouter: {
|
||||
autoStartServer,
|
||||
...(existingUserGemma.binaryPath !== undefined
|
||||
? { binaryPath: existingUserGemma.binaryPath }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Workspace scope: project-isolated settings so the local model only
|
||||
// runs for this specific project, saving resources globally.
|
||||
const existingWorkspaceGemma =
|
||||
settings.forScope(SettingScope.Workspace).settings.experimental
|
||||
?.gemmaModelRouter ?? {};
|
||||
const existingWorkspaceExperimental =
|
||||
settings.forScope(SettingScope.Workspace).settings.experimental ?? {};
|
||||
settings.setValue(SettingScope.Workspace, 'experimental', {
|
||||
...existingWorkspaceExperimental,
|
||||
gemmaModelRouter: {
|
||||
...existingWorkspaceGemma,
|
||||
enabled: true,
|
||||
classifier: {
|
||||
...existingWorkspaceGemma.classifier,
|
||||
host: `http://localhost:${port}`,
|
||||
model: GEMMA_MODEL_NAME,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log(chalk.green(' ✓ Settings updated'));
|
||||
log(chalk.dim(' User (~/.gemini/settings.json): autoStartServer'));
|
||||
log(
|
||||
chalk.dim(' Workspace (.gemini/settings.json): enabled, classifier'),
|
||||
);
|
||||
settingsUpdated = true;
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
logError(
|
||||
' You can manually add the configuration to ~/.gemini/settings.json',
|
||||
);
|
||||
}
|
||||
|
||||
if (argv.start) {
|
||||
log('');
|
||||
log(' Starting LiteRT server...');
|
||||
serverStarted = await startServer(binaryPath, port);
|
||||
if (serverStarted) {
|
||||
log(chalk.green(` ✓ Server started on port ${port}`));
|
||||
} else {
|
||||
log(
|
||||
chalk.yellow(
|
||||
` ! Server may not have started correctly. Check: gemini gemma status`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const routingActive = settingsUpdated && serverStarted;
|
||||
const setupSucceeded = settingsUpdated && (!argv.start || serverStarted);
|
||||
log('');
|
||||
log(chalk.dim('─'.repeat(40)));
|
||||
if (routingActive) {
|
||||
log(chalk.bold.green(' Setup complete! Local model routing is active.'));
|
||||
} else if (settingsUpdated) {
|
||||
log(
|
||||
chalk.bold.green(' Setup complete! Local model routing is configured.'),
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
chalk.bold.yellow(
|
||||
' Setup incomplete. Manual settings changes are still required.',
|
||||
),
|
||||
);
|
||||
}
|
||||
log('');
|
||||
log(' How it works: Every request is classified by the local Gemma model.');
|
||||
log(
|
||||
' Simple tasks (file reads, quick edits) route to ' +
|
||||
chalk.cyan('Flash') +
|
||||
' for speed.',
|
||||
);
|
||||
log(
|
||||
' Complex tasks (debugging, architecture) route to ' +
|
||||
chalk.cyan('Pro') +
|
||||
' for quality.',
|
||||
);
|
||||
log(' This happens automatically — just use the CLI as usual.');
|
||||
log('');
|
||||
if (!settingsUpdated) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Fix the settings update above, then rerun "gemini gemma status".',
|
||||
),
|
||||
);
|
||||
log('');
|
||||
} else if (!argv.start) {
|
||||
log(chalk.yellow(' Note: Run "gemini gemma start" to start the server.'));
|
||||
if (autoStartServer) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Or restart the CLI to auto-start it on the next launch.',
|
||||
),
|
||||
);
|
||||
}
|
||||
log('');
|
||||
} else if (!serverStarted) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Review the server logs and rerun "gemini gemma start" after fixing the issue.',
|
||||
),
|
||||
);
|
||||
log('');
|
||||
}
|
||||
log(' Useful commands:');
|
||||
log(chalk.dim(' gemini gemma status Check routing status'));
|
||||
log(chalk.dim(' gemini gemma start Start the LiteRT server'));
|
||||
log(chalk.dim(' gemini gemma stop Stop the LiteRT server'));
|
||||
log(chalk.dim(' /gemma Check status inside a session'));
|
||||
log('');
|
||||
|
||||
return setupSucceeded ? 0 : 1;
|
||||
}
|
||||
|
||||
export const setupCommand: CommandModule = {
|
||||
command: 'setup',
|
||||
describe: 'Download and configure Gemma local model routing',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('port', {
|
||||
type: 'number',
|
||||
default: DEFAULT_PORT,
|
||||
description: 'Port for the LiteRT server',
|
||||
})
|
||||
.option('skip-model', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Skip model download (binary only)',
|
||||
})
|
||||
.option('start', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Start the server after setup',
|
||||
})
|
||||
.option('force', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Re-download binary and model even if already present',
|
||||
})
|
||||
.option('consent', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Skip interactive consent prompt (implies acceptance)',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const exitCode = await handleSetup({
|
||||
port: Number(argv['port']),
|
||||
skipModel: Boolean(argv['skipModel']),
|
||||
start: Boolean(argv['start']),
|
||||
force: Boolean(argv['force']),
|
||||
consent: Boolean(argv['consent']),
|
||||
});
|
||||
await exitCli(exitCode);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
getPidFilePath,
|
||||
getLogFilePath,
|
||||
getLiteRtBinDir,
|
||||
SERVER_START_WAIT_MS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isServerRunning,
|
||||
resolveGemmaConfig,
|
||||
writeServerProcessInfo,
|
||||
} from './platform.js';
|
||||
|
||||
export async function startServer(
|
||||
binaryPath: string,
|
||||
port: number,
|
||||
): Promise<boolean> {
|
||||
const alreadyRunning = await isServerRunning(port);
|
||||
if (alreadyRunning) {
|
||||
debugLogger.log(`LiteRT server already running on port ${port}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const logPath = getLogFilePath();
|
||||
fs.mkdirSync(getLiteRtBinDir(), { recursive: true });
|
||||
const tmpDir = path.dirname(getPidFilePath());
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const logFd = fs.openSync(logPath, 'a');
|
||||
|
||||
try {
|
||||
const child = spawn(binaryPath, ['serve', `--port=${port}`, '--verbose'], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
|
||||
if (child.pid) {
|
||||
writeServerProcessInfo({
|
||||
pid: child.pid,
|
||||
binaryPath,
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
child.unref();
|
||||
} finally {
|
||||
fs.closeSync(logFd);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SERVER_START_WAIT_MS));
|
||||
return isServerRunning(port);
|
||||
}
|
||||
|
||||
export const startCommand: CommandModule = {
|
||||
command: 'start',
|
||||
describe: 'Start the LiteRT-LM server',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port for the LiteRT server',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
port = configuredPort;
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
if (!binaryPath || !isBinaryInstalled(binaryPath)) {
|
||||
debugLogger.error(
|
||||
chalk.red(
|
||||
'LiteRT-LM binary not found. Run "gemini gemma setup" first.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRunning = await isServerRunning(port);
|
||||
if (alreadyRunning) {
|
||||
debugLogger.log(
|
||||
chalk.green(`LiteRT server is already running on port ${port}.`),
|
||||
);
|
||||
await exitCli(0);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(`Starting LiteRT server on port ${port}...`);
|
||||
|
||||
const started = await startServer(binaryPath, port);
|
||||
if (started) {
|
||||
debugLogger.log(chalk.green(`LiteRT server started on port ${port}.`));
|
||||
debugLogger.log(chalk.dim(`Logs: ${getLogFilePath()}`));
|
||||
await exitCli(0);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
chalk.red('Server may not have started correctly. Check logs:'),
|
||||
);
|
||||
debugLogger.error(chalk.dim(` ${getLogFilePath()}`));
|
||||
await exitCli(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import { DEFAULT_PORT, GEMMA_MODEL_NAME } from './constants.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isModelDownloaded,
|
||||
isServerRunning,
|
||||
readServerPid,
|
||||
isProcessRunning,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export interface GemmaStatusResult {
|
||||
binaryInstalled: boolean;
|
||||
binaryPath: string | null;
|
||||
modelDownloaded: boolean;
|
||||
serverRunning: boolean;
|
||||
serverPid: number | null;
|
||||
settingsEnabled: boolean;
|
||||
port: number;
|
||||
allPassing: boolean;
|
||||
}
|
||||
|
||||
export async function checkGemmaStatus(
|
||||
port?: number,
|
||||
): Promise<GemmaStatusResult> {
|
||||
const { settingsEnabled, configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
|
||||
const effectivePort = port ?? configuredPort;
|
||||
const binaryPath = getBinaryPath();
|
||||
const binaryInstalled = isBinaryInstalled(binaryPath);
|
||||
const modelDownloaded =
|
||||
binaryInstalled && binaryPath ? isModelDownloaded(binaryPath) : false;
|
||||
const serverRunning = await isServerRunning(effectivePort);
|
||||
const pid = readServerPid();
|
||||
const serverPid = pid && isProcessRunning(pid) ? pid : null;
|
||||
|
||||
const allPassing =
|
||||
binaryInstalled && modelDownloaded && serverRunning && settingsEnabled;
|
||||
|
||||
return {
|
||||
binaryInstalled,
|
||||
binaryPath,
|
||||
modelDownloaded,
|
||||
serverRunning,
|
||||
serverPid,
|
||||
settingsEnabled,
|
||||
port: effectivePort,
|
||||
allPassing,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatGemmaStatus(status: GemmaStatusResult): string {
|
||||
const check = (ok: boolean) => (ok ? chalk.green('✓') : chalk.red('✗'));
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
chalk.bold('Gemma Local Model Routing Status'),
|
||||
chalk.dim('─'.repeat(40)),
|
||||
'',
|
||||
];
|
||||
|
||||
if (status.binaryInstalled) {
|
||||
lines.push(` Binary: ${check(true)} Installed (${status.binaryPath})`);
|
||||
} else {
|
||||
const platform = detectPlatform();
|
||||
if (platform) {
|
||||
lines.push(` Binary: ${check(false)} Not installed`);
|
||||
lines.push(chalk.dim(` Run: gemini gemma setup`));
|
||||
} else {
|
||||
lines.push(
|
||||
` Binary: ${check(false)} Unsupported platform (${process.platform}-${process.arch})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.modelDownloaded) {
|
||||
lines.push(` Model: ${check(true)} ${GEMMA_MODEL_NAME} downloaded`);
|
||||
} else {
|
||||
lines.push(` Model: ${check(false)} ${GEMMA_MODEL_NAME} not found`);
|
||||
if (status.binaryInstalled) {
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
` Run: ${status.binaryPath} pull ${GEMMA_MODEL_NAME}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
lines.push(chalk.dim(` Run: gemini gemma setup`));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.serverRunning) {
|
||||
const pidInfo = status.serverPid ? ` (PID ${status.serverPid})` : '';
|
||||
lines.push(
|
||||
` Server: ${check(true)} Running on port ${status.port}${pidInfo}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
` Server: ${check(false)} Not running on port ${status.port}`,
|
||||
);
|
||||
lines.push(chalk.dim(` Run: gemini gemma start`));
|
||||
}
|
||||
|
||||
if (status.settingsEnabled) {
|
||||
lines.push(` Settings: ${check(true)} Enabled in settings.json`);
|
||||
} else {
|
||||
lines.push(` Settings: ${check(false)} Not enabled in settings.json`);
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
` Run: gemini gemma setup (auto-configures settings)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
if (status.allPassing) {
|
||||
lines.push(chalk.green(' Routing is active — no action needed.'));
|
||||
lines.push('');
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
' Simple requests → Flash (fast) | Complex requests → Pro (powerful)',
|
||||
),
|
||||
);
|
||||
lines.push(chalk.dim(' This happens automatically on every request.'));
|
||||
} else {
|
||||
lines.push(
|
||||
chalk.yellow(
|
||||
' Some checks failed. Run "gemini gemma setup" for guided installation.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const statusCommand: CommandModule = {
|
||||
command: 'status',
|
||||
describe: 'Check Gemma local model routing status',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port to check for the LiteRT server',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
const status = await checkGemmaStatus(port);
|
||||
const output = formatGemmaStatus(status);
|
||||
process.stdout.write(output);
|
||||
await exitCli(status.allPassing ? 0 : 1);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockGetBinaryPath = vi.hoisted(() => vi.fn());
|
||||
const mockIsExpectedLiteRtServerProcess = vi.hoisted(() => vi.fn());
|
||||
const mockIsProcessRunning = vi.hoisted(() => vi.fn());
|
||||
const mockIsServerRunning = vi.hoisted(() => vi.fn());
|
||||
const mockReadServerPid = vi.hoisted(() => vi.fn());
|
||||
const mockReadServerProcessInfo = vi.hoisted(() => vi.fn());
|
||||
const mockResolveGemmaConfig = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('./constants.js', () => ({
|
||||
DEFAULT_PORT: 9379,
|
||||
getPidFilePath: vi.fn(() => '/tmp/litert-server.pid'),
|
||||
}));
|
||||
|
||||
vi.mock('./platform.js', () => ({
|
||||
getBinaryPath: mockGetBinaryPath,
|
||||
isExpectedLiteRtServerProcess: mockIsExpectedLiteRtServerProcess,
|
||||
isProcessRunning: mockIsProcessRunning,
|
||||
isServerRunning: mockIsServerRunning,
|
||||
readServerPid: mockReadServerPid,
|
||||
readServerProcessInfo: mockReadServerProcessInfo,
|
||||
resolveGemmaConfig: mockResolveGemmaConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
import { stopServer } from './stop.js';
|
||||
|
||||
describe('gemma stop command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockGetBinaryPath.mockReturnValue('/custom/lit');
|
||||
mockResolveGemmaConfig.mockReturnValue({ configuredPort: 9379 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('refuses to signal a pid that does not match the expected LiteRT server', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValue(true);
|
||||
mockIsExpectedLiteRtServerProcess.mockReturnValue(false);
|
||||
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
await expect(stopServer(8123)).resolves.toBe('unexpected-process');
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the verified LiteRT server and removes the pid file', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
mockIsExpectedLiteRtServerProcess.mockReturnValue(true);
|
||||
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const stopPromise = stopServer(8123);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(stopPromise).resolves.toBe('stopped');
|
||||
expect(killSpy).toHaveBeenCalledWith(1234, 'SIGTERM');
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
|
||||
});
|
||||
|
||||
it('cleans up a stale pid file when the recorded process is no longer running', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValue(false);
|
||||
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||
|
||||
await expect(stopServer(8123)).resolves.toBe('not-running');
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { DEFAULT_PORT, getPidFilePath } from './constants.js';
|
||||
import {
|
||||
getBinaryPath,
|
||||
isExpectedLiteRtServerProcess,
|
||||
isProcessRunning,
|
||||
isServerRunning,
|
||||
readServerPid,
|
||||
readServerProcessInfo,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
|
||||
export type StopServerResult =
|
||||
| 'stopped'
|
||||
| 'not-running'
|
||||
| 'unexpected-process'
|
||||
| 'failed';
|
||||
|
||||
export async function stopServer(
|
||||
expectedPort?: number,
|
||||
): Promise<StopServerResult> {
|
||||
const processInfo = readServerProcessInfo();
|
||||
const pidPath = getPidFilePath();
|
||||
|
||||
if (!processInfo) {
|
||||
return 'not-running';
|
||||
}
|
||||
|
||||
const { pid } = processInfo;
|
||||
if (!isProcessRunning(pid)) {
|
||||
debugLogger.log(
|
||||
`Stale PID file found (PID ${pid} is not running), removing ${pidPath}`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(pidPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'not-running';
|
||||
}
|
||||
|
||||
const binaryPath = processInfo.binaryPath ?? getBinaryPath();
|
||||
const port = processInfo.port ?? expectedPort;
|
||||
if (!isExpectedLiteRtServerProcess(pid, { binaryPath, port })) {
|
||||
debugLogger.warn(
|
||||
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
|
||||
);
|
||||
return 'unexpected-process';
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (isProcessRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
if (isProcessRunning(pid)) {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(pidPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
export const stopCommand: CommandModule = {
|
||||
command: 'stop',
|
||||
describe: 'Stop the LiteRT-LM server',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port where the LiteRT server is running',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
port = configuredPort;
|
||||
}
|
||||
|
||||
const processInfo = readServerProcessInfo();
|
||||
const pid = processInfo?.pid ?? readServerPid();
|
||||
|
||||
if (pid !== null && isProcessRunning(pid)) {
|
||||
debugLogger.log(`Stopping LiteRT server (PID ${pid})...`);
|
||||
const result = await stopServer(port);
|
||||
if (result === 'stopped') {
|
||||
debugLogger.log(chalk.green('LiteRT server stopped.'));
|
||||
await exitCli(0);
|
||||
} else if (result === 'unexpected-process') {
|
||||
debugLogger.error(
|
||||
chalk.red(
|
||||
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
|
||||
),
|
||||
);
|
||||
debugLogger.error(
|
||||
chalk.dim(
|
||||
'Remove the stale pid file after verifying the process, or stop the process manually.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
} else {
|
||||
debugLogger.error(chalk.red('Failed to stop LiteRT server.'));
|
||||
await exitCli(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await isServerRunning(port);
|
||||
if (running) {
|
||||
debugLogger.log(
|
||||
chalk.yellow(
|
||||
`A server is responding on port ${port}, but it was not started by "gemini gemma start".`,
|
||||
),
|
||||
);
|
||||
debugLogger.log(
|
||||
chalk.dim(
|
||||
'If you started it manually, stop it from the terminal where it is running.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
} else {
|
||||
debugLogger.log('No LiteRT server is currently running.');
|
||||
await exitCli(0);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { migrateCommand } from './hooks/migrate.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
|
||||
export const hooksCommand: CommandModule = {
|
||||
command: 'hooks <command>',
|
||||
aliases: ['hook'],
|
||||
describe: 'Manage Gemini CLI hooks.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(migrateCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { handleMigrateFromClaude } from './migrate.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedLoadSettings = loadSettings as Mock;
|
||||
const mockedFs = vi.mocked(fs);
|
||||
|
||||
describe('migrate command', () => {
|
||||
let mockSetValue: Mock;
|
||||
let debugLoggerLogSpy: MockInstance;
|
||||
let debugLoggerErrorSpy: MockInstance;
|
||||
let originalCwd: () => string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockSetValue = vi.fn();
|
||||
debugLoggerLogSpy = vi
|
||||
.spyOn(debugLogger, 'log')
|
||||
.mockImplementation(() => {});
|
||||
debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Mock process.cwd()
|
||||
originalCwd = process.cwd;
|
||||
process.cwd = vi.fn(() => '/test/project');
|
||||
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
hooks: {},
|
||||
},
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/test/project/.gemini' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.cwd = originalCwd;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should log error when no Claude settings files exist', async () => {
|
||||
mockedFs.existsSync.mockReturnValue(false);
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json',
|
||||
);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should migrate hooks from settings.json when it exists', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Edit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo "Before Edit"',
|
||||
timeout: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockImplementation((path) =>
|
||||
path.toString().endsWith('settings.json'),
|
||||
);
|
||||
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'hooks',
|
||||
expect.objectContaining({
|
||||
BeforeTool: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
matcher: 'replace',
|
||||
hooks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
command: 'echo "Before Edit"',
|
||||
type: 'command',
|
||||
timeout: 30,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Found Claude Code settings'),
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Migrating 1 hook event'),
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'✓ Hooks successfully migrated to .gemini/settings.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer settings.local.json over settings.json', async () => {
|
||||
const localSettings = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo "Local session start"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(localSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('settings.local.json'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'hooks',
|
||||
expect.objectContaining({
|
||||
SessionStart: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate all supported event types', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo 1' }] }],
|
||||
PostToolUse: [{ hooks: [{ type: 'command', command: 'echo 2' }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo 3' }] }],
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'echo 4' }] }],
|
||||
SubAgentStop: [{ hooks: [{ type: 'command', command: 'echo 5' }] }],
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'echo 6' }] }],
|
||||
SessionEnd: [{ hooks: [{ type: 'command', command: 'echo 7' }] }],
|
||||
PreCompact: [{ hooks: [{ type: 'command', command: 'echo 8' }] }],
|
||||
Notification: [{ hooks: [{ type: 'command', command: 'echo 9' }] }],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
|
||||
expect(migratedHooks).toHaveProperty('BeforeTool');
|
||||
expect(migratedHooks).toHaveProperty('AfterTool');
|
||||
expect(migratedHooks).toHaveProperty('BeforeAgent');
|
||||
expect(migratedHooks).toHaveProperty('AfterAgent');
|
||||
expect(migratedHooks).toHaveProperty('SessionStart');
|
||||
expect(migratedHooks).toHaveProperty('SessionEnd');
|
||||
expect(migratedHooks).toHaveProperty('PreCompress');
|
||||
expect(migratedHooks).toHaveProperty('Notification');
|
||||
});
|
||||
|
||||
it('should transform tool names in matchers', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Bash|Read|Write|Glob|Grep',
|
||||
hooks: [{ type: 'command', command: 'echo "test"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].matcher).toBe(
|
||||
'replace|run_shell_command|read_file|write_file|glob|grep',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'cd $CLAUDE_PROJECT_DIR && ls',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe(
|
||||
'cd $GEMINI_PROJECT_DIR && ls',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve sequential flag', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
sequential: true,
|
||||
hooks: [{ type: 'command', command: 'echo "test"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].sequential).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve timeout values', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo "test"',
|
||||
timeout: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].hooks[0].timeout).toBe(60);
|
||||
});
|
||||
|
||||
it('should merge with existing Gemini hooks', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo "claude"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo "existing"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/test/project/.gemini' },
|
||||
});
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks).toHaveProperty('BeforeTool');
|
||||
expect(migratedHooks).toHaveProperty('AfterTool');
|
||||
expect(migratedHooks.AfterTool[0].hooks[0].command).toBe('echo "existing"');
|
||||
expect(migratedHooks.BeforeTool[0].hooks[0].command).toBe('echo "claude"');
|
||||
});
|
||||
|
||||
it('should handle JSON with comments', async () => {
|
||||
const claudeSettingsWithComments = `{
|
||||
// This is a comment
|
||||
"hooks": {
|
||||
/* Block comment */
|
||||
"PreToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo test" // Inline comment
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`;
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(claudeSettingsWithComments);
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'hooks',
|
||||
expect.objectContaining({
|
||||
BeforeTool: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue('{ invalid json }');
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error reading'),
|
||||
);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log info when no hooks are found in Claude settings', async () => {
|
||||
const claudeSettings = {
|
||||
someOtherSetting: 'value',
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'No hooks found in Claude Code settings to migrate.',
|
||||
);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle setValue errors gracefully', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo "test"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
mockSetValue.mockImplementation(() => {
|
||||
throw new Error('Failed to save');
|
||||
});
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving migrated hooks: Failed to save',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle hooks with matcher but no command', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Edit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].matcher).toBe('replace');
|
||||
expect(migratedHooks.BeforeTool[0].hooks[0].type).toBe('command');
|
||||
});
|
||||
|
||||
it('should handle empty hooks array', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks.BeforeTool[0].hooks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle non-array event config gracefully', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: 'not an array',
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo "test"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
const migratedHooks = mockSetValue.mock.calls[0][2];
|
||||
expect(migratedHooks).not.toHaveProperty('BeforeTool');
|
||||
expect(migratedHooks).toHaveProperty('AfterTool');
|
||||
});
|
||||
|
||||
it('should display migration instructions after successful migration', async () => {
|
||||
const claudeSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'echo "test"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedFs.existsSync.mockReturnValue(true);
|
||||
mockedFs.readFileSync.mockReturnValue(JSON.stringify(claudeSettings));
|
||||
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'✓ Hooks successfully migrated to .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
interface MigrateArgs {
|
||||
fromClaude: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from Claude Code event names to Gemini event names
|
||||
*/
|
||||
const EVENT_MAPPING: Record<string, string> = {
|
||||
PreToolUse: 'BeforeTool',
|
||||
PostToolUse: 'AfterTool',
|
||||
UserPromptSubmit: 'BeforeAgent',
|
||||
Stop: 'AfterAgent',
|
||||
SubAgentStop: 'AfterAgent', // Gemini doesn't have sub-agents, map to AfterAgent
|
||||
SessionStart: 'SessionStart',
|
||||
SessionEnd: 'SessionEnd',
|
||||
PreCompact: 'PreCompress',
|
||||
Notification: 'Notification',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping from Claude Code tool names to Gemini tool names
|
||||
*/
|
||||
const TOOL_NAME_MAPPING: Record<string, string> = {
|
||||
Edit: 'replace',
|
||||
Bash: 'run_shell_command',
|
||||
Read: 'read_file',
|
||||
Write: 'write_file',
|
||||
Glob: 'glob',
|
||||
Grep: 'grep',
|
||||
LS: 'ls',
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a matcher regex to update tool names from Claude to Gemini
|
||||
*/
|
||||
function transformMatcher(matcher: string | undefined): string | undefined {
|
||||
if (!matcher) return matcher;
|
||||
|
||||
let transformed = matcher;
|
||||
for (const [claudeName, geminiName] of Object.entries(TOOL_NAME_MAPPING)) {
|
||||
// Replace exact matches and matches within regex alternations
|
||||
transformed = transformed.replace(
|
||||
new RegExp(`\\b${claudeName}\\b`, 'g'),
|
||||
geminiName,
|
||||
);
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a Claude Code hook configuration to Gemini format
|
||||
*/
|
||||
function migrateClaudeHook(claudeHook: unknown): unknown {
|
||||
if (!claudeHook || typeof claudeHook !== 'object') {
|
||||
return claudeHook;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hook = claudeHook as Record<string, unknown>;
|
||||
const migrated: Record<string, unknown> = {};
|
||||
|
||||
// Map command field
|
||||
if ('command' in hook) {
|
||||
migrated['command'] = hook['command'];
|
||||
|
||||
// Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (typeof migrated['command'] === 'string') {
|
||||
migrated['command'] = migrated['command'].replace(
|
||||
/\$CLAUDE_PROJECT_DIR/g,
|
||||
'$GEMINI_PROJECT_DIR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Map type field
|
||||
if ('type' in hook && hook['type'] === 'command') {
|
||||
migrated['type'] = 'command';
|
||||
}
|
||||
|
||||
// Map timeout field (Claude uses seconds, Gemini uses seconds)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if ('timeout' in hook && typeof hook['timeout'] === 'number') {
|
||||
migrated['timeout'] = hook['timeout'];
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate Claude Code hooks configuration to Gemini format
|
||||
*/
|
||||
function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
if (!claudeConfig || typeof claudeConfig !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = claudeConfig as Record<string, unknown>;
|
||||
const geminiHooks: Record<string, unknown> = {};
|
||||
|
||||
// Check if there's a hooks section
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
|
||||
if (!hooksSection || typeof hooksSection !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const [eventName, eventConfig] of Object.entries(hooksSection)) {
|
||||
// Map event name
|
||||
const geminiEventName = EVENT_MAPPING[eventName] || eventName;
|
||||
|
||||
if (!Array.isArray(eventConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Migrate each hook definition
|
||||
const migratedDefinitions = eventConfig.map((def: unknown) => {
|
||||
if (!def || typeof def !== 'object') {
|
||||
return def;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const definition = def as Record<string, unknown>;
|
||||
const migratedDef: Record<string, unknown> = {};
|
||||
|
||||
// Transform matcher
|
||||
if (
|
||||
'matcher' in definition &&
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof definition['matcher'] === 'string'
|
||||
) {
|
||||
migratedDef['matcher'] = transformMatcher(definition['matcher']);
|
||||
}
|
||||
|
||||
// Copy sequential flag
|
||||
if ('sequential' in definition) {
|
||||
migratedDef['sequential'] = definition['sequential'];
|
||||
}
|
||||
|
||||
// Migrate hooks array
|
||||
if ('hooks' in definition && Array.isArray(definition['hooks'])) {
|
||||
migratedDef['hooks'] = definition['hooks'].map(migrateClaudeHook);
|
||||
}
|
||||
|
||||
return migratedDef;
|
||||
});
|
||||
|
||||
geminiHooks[geminiEventName] = migratedDefinitions;
|
||||
}
|
||||
|
||||
return geminiHooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle migration from Claude Code
|
||||
*/
|
||||
export async function handleMigrateFromClaude() {
|
||||
const workingDir = process.cwd();
|
||||
|
||||
// Look for Claude settings in .claude directory
|
||||
const claudeDir = path.join(workingDir, '.claude');
|
||||
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
const claudeLocalSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
|
||||
let claudeSettings: Record<string, unknown> | null = null;
|
||||
let sourceFile = '';
|
||||
|
||||
// Try to read settings.local.json first, then settings.json
|
||||
if (fs.existsSync(claudeLocalSettingsPath)) {
|
||||
sourceFile = claudeLocalSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error reading ${claudeLocalSettingsPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
} else if (fs.existsSync(claudeSettingsPath)) {
|
||||
sourceFile = claudeSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error reading ${claudeSettingsPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugLogger.error(
|
||||
'No Claude Code settings found in .claude directory. Expected settings.json or settings.local.json',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!claudeSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(`Found Claude Code settings in: ${sourceFile}`);
|
||||
|
||||
// Migrate hooks
|
||||
const migratedHooks = migrateClaudeHooks(claudeSettings);
|
||||
|
||||
if (Object.keys(migratedHooks).length === 0) {
|
||||
debugLogger.log('No hooks found in Claude Code settings to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Migrating ${Object.keys(migratedHooks).length} hook event(s)...`,
|
||||
);
|
||||
|
||||
// Load current Gemini settings
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
// Merge migrated hooks with existing hooks
|
||||
const existingHooks = (settings.merged?.hooks || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mergedHooks = { ...existingHooks, ...migratedHooks };
|
||||
|
||||
// Update settings (setValue automatically saves)
|
||||
try {
|
||||
settings.setValue(SettingScope.Workspace, 'hooks', mergedHooks);
|
||||
|
||||
debugLogger.log('✓ Hooks successfully migrated to .gemini/settings.json');
|
||||
debugLogger.log(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const migrateCommand: CommandModule = {
|
||||
command: 'migrate',
|
||||
describe: 'Migrate hooks from Claude Code to Gemini CLI',
|
||||
builder: (yargs) =>
|
||||
yargs.option('from-claude', {
|
||||
describe: 'Migrate from Claude Code hooks',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const args = argv as unknown as MigrateArgs;
|
||||
if (args.fromClaude) {
|
||||
await handleMigrateFromClaude();
|
||||
} else {
|
||||
debugLogger.log(
|
||||
'Usage: gemini hooks migrate --from-claude\n\nMigrate hooks from Claude Code to Gemini CLI format.',
|
||||
);
|
||||
}
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mcpCommand } from './mcp.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
|
||||
describe('mcp command', () => {
|
||||
it('should have correct command definition', () => {
|
||||
expect(mcpCommand.command).toBe('mcp');
|
||||
expect(mcpCommand.describe).toBe('Manage MCP servers');
|
||||
expect(typeof mcpCommand.builder).toBe('function');
|
||||
expect(typeof mcpCommand.handler).toBe('function');
|
||||
});
|
||||
|
||||
it('should show help when no subcommand is provided', async () => {
|
||||
const yargsInstance = yargs();
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
|
||||
|
||||
const parser = yargsInstance.command(mcpCommand).help();
|
||||
|
||||
// Mock console.log and console.error to catch help output
|
||||
const consoleLogMock = vi
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => {});
|
||||
const consoleErrorMock = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await parser.parse('mcp');
|
||||
} catch {
|
||||
// yargs might throw an error when demandCommand is not met
|
||||
}
|
||||
|
||||
// Check if help output is shown
|
||||
const helpOutput =
|
||||
consoleLogMock.mock.calls.join('\n') +
|
||||
consoleErrorMock.mock.calls.join('\n');
|
||||
expect(helpOutput).toContain('Manage MCP servers');
|
||||
expect(helpOutput).toContain('Commands:');
|
||||
expect(helpOutput).toContain('add');
|
||||
expect(helpOutput).toContain('remove');
|
||||
expect(helpOutput).toContain('list');
|
||||
|
||||
consoleLogMock.mockRestore();
|
||||
consoleErrorMock.mockRestore();
|
||||
});
|
||||
|
||||
it('should register add, remove, and list subcommands', () => {
|
||||
const mockYargs = {
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
middleware: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Verify that the specific subcommands are registered
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
const commandNames = commandCalls.map((call) => call[0].command);
|
||||
|
||||
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
||||
expect(commandNames).toContain('remove <name>');
|
||||
expect(commandNames).toContain('list');
|
||||
expect(commandNames).toContain('enable <name>');
|
||||
expect(commandNames).toContain('disable <name>');
|
||||
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||
1,
|
||||
'You need at least one command before continuing.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp' command
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
import { enableCommand, disableCommand } from './mcp/enableDisable.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const mcpCommand: CommandModule = {
|
||||
command: 'mcp',
|
||||
describe: 'Manage MCP servers',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(addCommand, 'mcp'))
|
||||
.command(defer(removeCommand, 'mcp'))
|
||||
.command(defer(listCommand, 'mcp'))
|
||||
.command(defer(enableCommand, 'mcp'))
|
||||
.command(defer(disableCommand, 'mcp'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// yargs will automatically show help if no subcommand is provided
|
||||
// thanks to demandCommand(1) in the builder.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { addCommand } from './add.js';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('os', () => {
|
||||
const homedir = vi.fn(() => '/home/user');
|
||||
return {
|
||||
default: {
|
||||
homedir,
|
||||
},
|
||||
homedir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedLoadSettings = loadSettings as Mock;
|
||||
|
||||
describe('mcp add command', () => {
|
||||
let parser: Argv;
|
||||
let mockSetValue: Mock;
|
||||
let mockConsoleError: Mock;
|
||||
let debugLoggerErrorSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const yargsInstance = yargs([]).command(addCommand);
|
||||
parser = yargsInstance;
|
||||
mockSetValue = vi.fn();
|
||||
mockConsoleError = vi.fn();
|
||||
debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(mockConsoleError);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/path/to/project' },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a stdio server to project settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add -e FOO=bar my-server /path/to/server arg1 arg2',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'my-server': {
|
||||
command: '/path/to/server',
|
||||
args: ['arg1', 'arg2'],
|
||||
env: { FOO: 'bar' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple env vars before positional args', async () => {
|
||||
await parser.parseAsync(
|
||||
'add -e FOO=bar -e BAZ=qux my-server /path/to/server',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'my-server': {
|
||||
command: '/path/to/server',
|
||||
args: [],
|
||||
env: { FOO: 'bar', BAZ: 'qux' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add an sse server to user settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --transport sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse-endpoint',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
|
||||
'sse-server': {
|
||||
url: 'https://example.com/sse-endpoint',
|
||||
type: 'sse',
|
||||
headers: { 'X-API-Key': 'your-key' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add an http server to project settings', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --transport http -H "Authorization: Bearer your-token" http-server https://example.com/mcp',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'http-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
type: 'http',
|
||||
headers: { Authorization: 'Bearer your-token' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add an sse server using --type alias', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --type sse --scope user -H "X-API-Key: your-key" sse-server https://example.com/sse',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
|
||||
'sse-server': {
|
||||
url: 'https://example.com/sse',
|
||||
type: 'sse',
|
||||
headers: { 'X-API-Key': 'your-key' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add an http server using --type alias', async () => {
|
||||
await parser.parseAsync(
|
||||
'add --type http -H "Authorization: Bearer your-token" http-server https://example.com/mcp',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'http-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
type: 'http',
|
||||
headers: { Authorization: 'Bearer your-token' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle MCP server args with -- separator', async () => {
|
||||
await parser.parseAsync(
|
||||
'add my-server npx -- -y http://example.com/some-package',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'my-server': {
|
||||
command: 'npx',
|
||||
args: ['-y', 'http://example.com/some-package'],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unknown options as MCP server args', async () => {
|
||||
await parser.parseAsync(
|
||||
'add test-server npx -y http://example.com/some-package',
|
||||
);
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
{
|
||||
'test-server': {
|
||||
command: 'npx',
|
||||
args: ['-y', 'http://example.com/some-package'],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when handling scope and directory', () => {
|
||||
const serverName = 'test-server';
|
||||
const command = 'echo';
|
||||
|
||||
const setupMocks = (cwd: string, workspacePath: string) => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: workspacePath },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
};
|
||||
|
||||
describe('when in a project directory', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/path/to/project', '/path/to/project');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use project scope when --scope=project is used', async () => {
|
||||
await parser.parseAsync(`add --scope project ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use user scope when --scope=user is used', async () => {
|
||||
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in a subdirectory of a project', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/path/to/project/subdir', '/path/to/project');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in the home directory', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/home/user', '/home/user');
|
||||
});
|
||||
|
||||
it('should show an error by default', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as (code?: number | string | null) => never);
|
||||
|
||||
await expect(
|
||||
parser.parseAsync(`add ${serverName} ${command}`),
|
||||
).rejects.toThrow('process.exit called');
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show an error when --scope=project is used explicitly', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as (code?: number | string | null) => never);
|
||||
|
||||
await expect(
|
||||
parser.parseAsync(`add --scope project ${serverName} ${command}`),
|
||||
).rejects.toThrow('process.exit called');
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use user scope when --scope=user is used', async () => {
|
||||
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in a subdirectory of home (not a project)', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/home/user/some/dir', '/home/user/some/dir');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write to the WORKSPACE scope, not the USER scope', async () => {
|
||||
await parser.parseAsync(`add my-new-server echo`);
|
||||
|
||||
// We expect setValue to be called once.
|
||||
expect(mockSetValue).toHaveBeenCalledTimes(1);
|
||||
|
||||
// We get the scope that setValue was called with.
|
||||
const calledScope = mockSetValue.mock.calls[0][0];
|
||||
|
||||
// We assert that the scope was Workspace, not User.
|
||||
expect(calledScope).toBe(SettingScope.Workspace);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when outside of home (not a project)', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/tmp/foo', '/tmp/foo');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updating an existing server', () => {
|
||||
const serverName = 'existing-server';
|
||||
const initialCommand = 'echo old';
|
||||
const updatedCommand = 'echo';
|
||||
const updatedArgs = ['new'];
|
||||
|
||||
beforeEach(() => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({
|
||||
settings: {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: initialCommand,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/path/to/project' },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the existing server in the project scope', async () => {
|
||||
await parser.parseAsync(
|
||||
`add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
|
||||
);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.objectContaining({
|
||||
[serverName]: expect.objectContaining({
|
||||
command: updatedCommand,
|
||||
args: updatedArgs,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the existing server in the user scope', async () => {
|
||||
await parser.parseAsync(
|
||||
`add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
|
||||
);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.objectContaining({
|
||||
[serverName]: expect.objectContaining({
|
||||
command: updatedCommand,
|
||||
args: updatedArgs,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// File for 'gemini mcp add' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
async function addMcpServer(
|
||||
name: string,
|
||||
commandOrUrl: string,
|
||||
args: Array<string | number> | undefined,
|
||||
options: {
|
||||
scope: string;
|
||||
transport: string;
|
||||
env: string[] | undefined;
|
||||
header: string[] | undefined;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
},
|
||||
) {
|
||||
const {
|
||||
scope,
|
||||
transport,
|
||||
env,
|
||||
header,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
} = options;
|
||||
|
||||
const settings = loadSettings(process.cwd());
|
||||
const inHome = settings.workspace.path === settings.user.path;
|
||||
|
||||
if (scope === 'project' && inHome) {
|
||||
debugLogger.error(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const settingsScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
|
||||
let newServer: Partial<MCPServerConfig> = {};
|
||||
|
||||
const headers = header?.reduce(
|
||||
(acc, curr) => {
|
||||
const [key, ...valueParts] = curr.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() && value) {
|
||||
acc[key.trim()] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
switch (transport) {
|
||||
case 'sse':
|
||||
newServer = {
|
||||
url: commandOrUrl,
|
||||
type: 'sse',
|
||||
headers,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
case 'http':
|
||||
newServer = {
|
||||
url: commandOrUrl,
|
||||
type: 'http',
|
||||
headers,
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
case 'stdio':
|
||||
default:
|
||||
newServer = {
|
||||
command: commandOrUrl,
|
||||
args: args?.map(String),
|
||||
env: env?.reduce(
|
||||
(acc, curr) => {
|
||||
const [key, value] = curr.split('=');
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
timeout,
|
||||
trust,
|
||||
description,
|
||||
includeTools,
|
||||
excludeTools,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const existingSettings = settings.forScope(settingsScope).settings;
|
||||
const mcpServers = existingSettings.mcpServers || {};
|
||||
|
||||
const isExistingServer = !!mcpServers[name];
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(
|
||||
`MCP server "${name}" is already configured within ${scope} settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
mcpServers[name] = newServer as MCPServerConfig;
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`MCP server "${name}" added to ${scope} settings. (${transport})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const addCommand: CommandModule = {
|
||||
command: 'add <name> <commandOrUrl> [args...]',
|
||||
describe: 'Add a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.parserConfiguration({
|
||||
'unknown-options-as-args': true, // Pass unknown options as server args
|
||||
'populate--': true, // Populate server args after -- separator
|
||||
})
|
||||
.positional('name', {
|
||||
describe: 'Name of the server',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('commandOrUrl', {
|
||||
describe: 'Command (stdio) or URL (sse, http)',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
alias: 's',
|
||||
describe: 'Configuration scope (user or project)',
|
||||
type: 'string',
|
||||
default: 'project',
|
||||
choices: ['user', 'project'],
|
||||
})
|
||||
.option('transport', {
|
||||
alias: ['t', 'type'],
|
||||
describe: 'Transport type (stdio, sse, http)',
|
||||
type: 'string',
|
||||
default: 'stdio',
|
||||
choices: ['stdio', 'sse', 'http'],
|
||||
})
|
||||
.option('env', {
|
||||
alias: 'e',
|
||||
describe: 'Set environment variables (e.g. -e KEY=value)',
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
})
|
||||
.option('header', {
|
||||
alias: 'H',
|
||||
describe:
|
||||
'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
})
|
||||
.option('timeout', {
|
||||
describe: 'Set connection timeout in milliseconds',
|
||||
type: 'number',
|
||||
})
|
||||
.option('trust', {
|
||||
describe:
|
||||
'Trust the server (bypass all tool call confirmation prompts)',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('description', {
|
||||
describe: 'Set the description for the server',
|
||||
type: 'string',
|
||||
})
|
||||
.option('include-tools', {
|
||||
describe: 'A comma-separated list of tools to include',
|
||||
type: 'array',
|
||||
string: true,
|
||||
})
|
||||
.option('exclude-tools', {
|
||||
describe: 'A comma-separated list of tools to exclude',
|
||||
type: 'array',
|
||||
string: true,
|
||||
})
|
||||
.middleware((argv) => {
|
||||
// Handle -- separator args as server args if present
|
||||
if (argv['--']) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const existingArgs = (argv['args'] as Array<string | number>) || [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
|
||||
}
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await addMcpServer(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['commandOrUrl'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] as Array<string | number>,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
transport: argv['transport'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: argv['env'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
header: argv['header'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
timeout: argv['timeout'] as number | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
trust: argv['trust'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
description: argv['description'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
includeTools: argv['includeTools'] as string[] | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
excludeTools: argv['excludeTools'] as string[] | undefined,
|
||||
},
|
||||
);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getMcpServersFromConfig } from './list.js';
|
||||
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const RED = '\x1b[31m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
interface Args {
|
||||
name: string;
|
||||
session?: boolean;
|
||||
}
|
||||
|
||||
async function handleEnable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Check settings blocks
|
||||
const settings = loadSettings();
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canLoadServer(name, {
|
||||
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
|
||||
allowedList: settings.merged.mcp?.allowed,
|
||||
excludedList: settings.merged.mcp?.excluded,
|
||||
});
|
||||
|
||||
if (
|
||||
!result.allowed &&
|
||||
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
|
||||
) {
|
||||
debugLogger.log(`${RED}Error:${RESET} ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.clearSessionDisable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`);
|
||||
} else {
|
||||
await manager.enable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`);
|
||||
}
|
||||
|
||||
if (result.blockType === 'admin') {
|
||||
debugLogger.log(
|
||||
`${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.disableForSession(name);
|
||||
debugLogger.log(
|
||||
`${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`,
|
||||
);
|
||||
} else {
|
||||
await manager.disable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule<object, Args> = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Clear session-only disable',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
export const disableCommand: CommandModule<object, Args> = {
|
||||
command: 'disable <name>',
|
||||
describe: 'Disable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Disable for current session only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
createTransport,
|
||||
debugLogger,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionStorage } from '../../config/extensions/storage.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/index.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../config/extensions/storage.js', () => ({
|
||||
ExtensionStorage: {
|
||||
getUserExtensionsDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
createTransport: vi.fn(),
|
||||
|
||||
MCPServerStatus: {
|
||||
CONNECTED: 'CONNECTED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
BLOCKED: 'BLOCKED',
|
||||
DISABLED: 'DISABLED',
|
||||
},
|
||||
Storage: Object.assign(
|
||||
vi.fn().mockImplementation((_cwd: string) => ({
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
|
||||
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
|
||||
})),
|
||||
{
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
getGlobalGeminiDir: () => '/tmp/gemini',
|
||||
},
|
||||
),
|
||||
GEMINI_DIR: '.gemini',
|
||||
getErrorMessage: (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
});
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetUserExtensionsDir =
|
||||
ExtensionStorage.getUserExtensionsDir as Mock;
|
||||
const mockedLoadSettings = loadSettings as Mock;
|
||||
const mockedCreateTransport = createTransport as Mock;
|
||||
const MockedClient = Client as Mock;
|
||||
const MockedExtensionManager = ExtensionManager as Mock;
|
||||
|
||||
interface MockClient {
|
||||
connect: Mock;
|
||||
ping: Mock;
|
||||
close: Mock;
|
||||
}
|
||||
|
||||
interface MockExtensionManager {
|
||||
loadExtensions: Mock;
|
||||
}
|
||||
|
||||
interface MockTransport {
|
||||
close: Mock;
|
||||
}
|
||||
|
||||
describe('mcp list command', () => {
|
||||
let mockClient: MockClient;
|
||||
let mockExtensionManager: MockExtensionManager;
|
||||
let mockTransport: MockTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
||||
McpServerEnablementManager.resetInstance();
|
||||
// Use a mock for isFileEnabled to avoid reading real files
|
||||
vi.spyOn(
|
||||
McpServerEnablementManager.prototype,
|
||||
'isFileEnabled',
|
||||
).mockResolvedValue(true);
|
||||
|
||||
mockTransport = { close: vi.fn() };
|
||||
mockClient = {
|
||||
connect: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockExtensionManager = {
|
||||
loadExtensions: vi.fn(),
|
||||
};
|
||||
|
||||
MockedClient.mockImplementation(() => mockClient);
|
||||
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
|
||||
mockedCreateTransport.mockResolvedValue(mockTransport);
|
||||
mockExtensionManager.loadExtensions.mockReturnValue([]);
|
||||
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should display message when no servers configured', async () => {
|
||||
mockedLoadSettings.mockReturnValue(createMockSettings({ mcpServers: {} }));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.');
|
||||
});
|
||||
|
||||
it('should display different server types with connected status', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
|
||||
'sse-server': { url: 'https://example.com/sse', type: 'sse' },
|
||||
'http-server': { httpUrl: 'https://example.com/http' },
|
||||
'http-server-by-default': { url: 'https://example.com/http' },
|
||||
'http-server-with-type': {
|
||||
url: 'https://example.com/http',
|
||||
type: 'http',
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith('Configured MCP servers:\n');
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'stdio-server: /path/to/server arg1 (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'sse-server: https://example.com/sse (sse) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server-by-default: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server-with-type: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display disconnected status when connection fails', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockClient.connect.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'test-server: /test/server (stdio) - Disconnected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display connected status even if ping fails', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockRejectedValue(new Error('Ping failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Connected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use configured timeout for connection', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server', timeout: 12345 },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout for connection when not configured', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'config-server': { command: '/config/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mockExtensionManager.loadExtensions.mockReturnValue([
|
||||
{
|
||||
name: 'test-extension',
|
||||
mcpServers: { 'extension-server': { command: '/ext/server' } },
|
||||
},
|
||||
]);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'config-server: /config/server (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'extension-server (from test-extension): /ext/server (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers based on admin allowlist passed in settings', async () => {
|
||||
const adminControls = {
|
||||
strictModeDisabled: true,
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
mcpConfig: {
|
||||
mcpServers: {
|
||||
'allowed-server': { url: 'http://allowed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mcpServers = {
|
||||
'allowed-server': { command: 'cmd1' },
|
||||
'forbidden-server': { command: 'cmd2' },
|
||||
};
|
||||
|
||||
const mockSettings = createMockSettings({
|
||||
mcpServers,
|
||||
isTrusted: true,
|
||||
});
|
||||
// setRemoteAdminSettings is the correct way to set admin settings in tests
|
||||
(
|
||||
mockSettings as unknown as {
|
||||
setRemoteAdminSettings: (controls: AdminControlsSettings) => void;
|
||||
}
|
||||
).setRemoteAdminSettings(adminControls as unknown as AdminControlsSettings);
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers(mockSettings);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('allowed-server'),
|
||||
);
|
||||
expect(debugLogger.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('forbidden-server'),
|
||||
);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server',
|
||||
expect.objectContaining({ url: 'http://allowed' }), // Should use admin config
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show stdio servers as disabled in untrusted folders', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Warning: MCP servers are configured but disabled because this folder is untrusted.',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Disabled'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display blocked status for servers in excluded list', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display disabled status for servers disabled via enablement manager', async () => {
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
mcpServers: {
|
||||
'disabled-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.spyOn(
|
||||
McpServerEnablementManager.prototype,
|
||||
'isFileEnabled',
|
||||
).mockResolvedValue(false);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'disabled-server: /test/server (stdio) - Disabled',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display warning and disabled status in untrusted folders', async () => {
|
||||
const userMcpServers = {
|
||||
'user-server': { url: 'https://example.com/user' },
|
||||
};
|
||||
const workspaceMcpServers = {
|
||||
'project-server': { command: '/path/to/project/server' },
|
||||
};
|
||||
|
||||
mockedLoadSettings.mockReturnValue(
|
||||
createMockSettings({
|
||||
user: {
|
||||
settings: { mcpServers: userMcpServers },
|
||||
originalSettings: { mcpServers: userMcpServers },
|
||||
path: '/mock/user/settings.json',
|
||||
},
|
||||
workspace: {
|
||||
settings: { mcpServers: workspaceMcpServers },
|
||||
originalSettings: { mcpServers: workspaceMcpServers },
|
||||
path: '/mock/workspace/settings.json',
|
||||
},
|
||||
isTrusted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Warning: MCP servers are configured but disabled because this folder is untrusted.',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'project-server: /path/to/project/server (stdio) - Disabled',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'user-server: https://example.com/user (http) - Disabled',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
excluded: [], // workspace has overridden user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block servers case-insensitively when excluded', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
excluded: ['BLOCKED-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcpServers: {
|
||||
'blocked-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'blocked-server: /test/server (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restrict allowed servers to the intersection of all defined allowlists', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'allowed-server-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings!
|
||||
},
|
||||
mcpServers: {
|
||||
'allowed-server-1': { command: '/allowed/1' },
|
||||
'allowed-server-2': { command: '/allowed/2' },
|
||||
'malicious-server': { command: '/malicious' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// allowed-server-1 is in the intersection, so it should connect
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-1: /allowed/1 (stdio) - Connected',
|
||||
),
|
||||
);
|
||||
// allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'allowed-server-2: /allowed/2 (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'malicious-server: /malicious (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockedCreateTransport).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server-1',
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
user: {
|
||||
path: '/user/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['user-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
path: '/workspace/settings.json',
|
||||
settings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
originalSettings: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'],
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
isTrusted: true,
|
||||
merged: {
|
||||
mcp: {
|
||||
allowed: ['workspace-allowed-server'], // workspace override
|
||||
},
|
||||
mcpServers: {
|
||||
'user-allowed-server': { command: '/allowed/user' },
|
||||
'workspace-allowed-server': { command: '/allowed/workspace' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
// Since the intersection is empty ([]), both servers should be Blocked!
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'user-allowed-server: /allowed/user (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'workspace-allowed-server: /allowed/workspace (stdio) - Blocked',
|
||||
),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block all servers if allowlist is configured as empty array []', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
mcp: {
|
||||
allowed: [], // empty allowlist configured!
|
||||
},
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockedLoadSettings.mockReturnValue(mockSettings);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Blocked'),
|
||||
);
|
||||
expect(mockedCreateTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user