mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1f2070ea9 | |||
| 8d96c8ce72 | |||
| d0c6a56c65 | |||
| d5dfae6bbf | |||
| b61a123da8 | |||
| bed3eae0e1 | |||
| 878f57c1b7 | |||
| 3be90e0dea | |||
| cb4cbf8d94 | |||
| a02b71df07 | |||
| 0b23546fb8 | |||
| 73eb1247ea | |||
| 9289b879a5 | |||
| a5816a6765 | |||
| 49b70d69e5 |
@@ -21,3 +21,4 @@ junit.xml
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
**/SKILL.md
|
**/SKILL.md
|
||||||
|
packages/sdk/test-data/*.json
|
||||||
|
|||||||
@@ -239,6 +239,18 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['packages/sdk/src/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
name: '@google/gemini-cli-sdk',
|
||||||
|
message: 'Please use relative imports within the @google/gemini-cli-sdk package.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
Generated
+21
@@ -1389,6 +1389,10 @@
|
|||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/gemini-cli-sdk": {
|
||||||
|
"resolved": "packages/sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@google/gemini-cli-test-utils": {
|
"node_modules/@google/gemini-cli-test-utils": {
|
||||||
"resolved": "packages/test-utils",
|
"resolved": "packages/test-utils",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -17557,6 +17561,23 @@
|
|||||||
"uuid": "dist-node/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/sdk": {
|
||||||
|
"name": "@google/gemini-cli-sdk",
|
||||||
|
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@google/gemini-cli-core": "file:../core",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.23.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@google/gemini-cli-test-utils",
|
"name": "@google/gemini-cli-test-utils",
|
||||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||||
|
|||||||
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
|
|||||||
'Failed to fetch extensions: Not Found',
|
'Failed to fetch extensions: Not Found',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should deduplicate extensions by ID', async () => {
|
||||||
|
const duplicateExtensions = [
|
||||||
|
mockExtensions[0],
|
||||||
|
mockExtensions[0], // Duplicate
|
||||||
|
mockExtensions[1],
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => duplicateExtensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.getAllExtensions();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('ext1');
|
||||||
|
expect(result[1].id).toBe('ext2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return irrelevant results for specific queries', async () => {
|
||||||
|
const extensions = [
|
||||||
|
{
|
||||||
|
...mockExtensions[0],
|
||||||
|
id: 'conductor',
|
||||||
|
extensionName: 'conductor',
|
||||||
|
extensionDescription:
|
||||||
|
'Conductor is a Gemini CLI extension that allows you to specify, plan, and implement software features.',
|
||||||
|
fullName: 'google/conductor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mockExtensions[1],
|
||||||
|
id: 'dataplex',
|
||||||
|
extensionName: 'dataplex',
|
||||||
|
extensionDescription:
|
||||||
|
'Connect to Dataplex Universal Catalog to discover, manage, monitor, and govern data and AI artifacts across your data platform',
|
||||||
|
fullName: 'google/dataplex',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await client.searchExtensions('conductor');
|
||||||
|
|
||||||
|
// Conductor should definitely be first
|
||||||
|
expect(results[0].id).toBe('conductor');
|
||||||
|
|
||||||
|
// Dataplex should ideally NOT be in the results, or at least be ranked lower (which it will be if it matches at all).
|
||||||
|
// But user complaint is that it IS in results.
|
||||||
|
// Let's assert it is NOT in results if we want strictness.
|
||||||
|
const ids = results.map((r) => r.id);
|
||||||
|
expect(ids).not.toContain('dataplex');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export class ExtensionRegistryClient {
|
|||||||
ExtensionRegistryClient.fetchPromise = null;
|
ExtensionRegistryClient.fetchPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all extensions from the registry.
|
||||||
|
*/
|
||||||
|
async getAllExtensions(): Promise<RegistryExtension[]> {
|
||||||
|
return this.fetchAllExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
async getExtensions(
|
async getExtensions(
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
@@ -78,11 +85,24 @@ export class ExtensionRegistryClient {
|
|||||||
|
|
||||||
const fzf = new AsyncFzf(allExtensions, {
|
const fzf = new AsyncFzf(allExtensions, {
|
||||||
selector: (ext: RegistryExtension) =>
|
selector: (ext: RegistryExtension) =>
|
||||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
`${ext.extensionName} ${ext.extensionDescription || ''} ${
|
||||||
|
ext.fullName || ''
|
||||||
|
}`,
|
||||||
fuzzy: 'v2',
|
fuzzy: 'v2',
|
||||||
});
|
});
|
||||||
const results = await fzf.find(query);
|
const results = await fzf.find(query);
|
||||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScore = results[0].score;
|
||||||
|
const THRESHOLD_RATIO = 0.75;
|
||||||
|
const threshold = maxScore * THRESHOLD_RATIO;
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter((r: { score: number }) => r.score >= threshold)
|
||||||
|
.map((r: { item: RegistryExtension }) => r.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
|
type SlashCommandActionReturn,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
@@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises';
|
|||||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||||
|
import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
function showMessageIfNoExtensions(
|
function showMessageIfNoExtensions(
|
||||||
@@ -265,7 +267,28 @@ async function restartAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exploreAction(context: CommandContext) {
|
async function exploreAction(
|
||||||
|
context: CommandContext,
|
||||||
|
): Promise<SlashCommandActionReturn | void> {
|
||||||
|
const settings = context.services.settings.merged;
|
||||||
|
const useRegistryUI = settings.experimental?.extensionRegistry;
|
||||||
|
|
||||||
|
if (useRegistryUI) {
|
||||||
|
const extensionManager = context.services.config?.getExtensionLoader();
|
||||||
|
if (extensionManager instanceof ExtensionManager) {
|
||||||
|
return {
|
||||||
|
type: 'custom_dialog' as const,
|
||||||
|
component: React.createElement(ExtensionRegistryView, {
|
||||||
|
onSelect: (extension) => {
|
||||||
|
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
|
||||||
|
},
|
||||||
|
onClose: () => context.ui.removeComponent(),
|
||||||
|
extensionManager,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||||
|
|
||||||
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
// Only check for NODE_ENV for explicit test mode, not for unit test framework
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which authentication method should we use?',
|
question: 'Which authentication method should we use?',
|
||||||
header: 'Auth',
|
header: 'Auth',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
|
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
|
||||||
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
|
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
|
||||||
@@ -74,6 +75,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which features?',
|
question: 'Which features?',
|
||||||
header: 'Features',
|
header: 'Features',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'TypeScript', description: '' },
|
{ label: 'TypeScript', description: '' },
|
||||||
{ label: 'ESLint', description: '' },
|
{ label: 'ESLint', description: '' },
|
||||||
@@ -171,6 +173,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which authentication method?',
|
question: 'Which authentication method?',
|
||||||
header: 'Auth',
|
header: 'Auth',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'OAuth 2.0', description: '' }],
|
options: [{ label: 'OAuth 2.0', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -228,6 +231,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Choose an option',
|
question: 'Choose an option',
|
||||||
header: 'Scroll Test',
|
header: 'Scroll Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: Array.from({ length: 15 }, (_, i) => ({
|
options: Array.from({ length: 15 }, (_, i) => ({
|
||||||
label: `Option ${i + 1}`,
|
label: `Option ${i + 1}`,
|
||||||
description: `Description ${i + 1}`,
|
description: `Description ${i + 1}`,
|
||||||
@@ -296,6 +300,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which database should we use?',
|
question: 'Which database should we use?',
|
||||||
header: 'Database',
|
header: 'Database',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'PostgreSQL', description: 'Relational database' },
|
{ label: 'PostgreSQL', description: 'Relational database' },
|
||||||
{ label: 'MongoDB', description: 'Document database' },
|
{ label: 'MongoDB', description: 'Document database' },
|
||||||
@@ -305,6 +310,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which ORM do you prefer?',
|
question: 'Which ORM do you prefer?',
|
||||||
header: 'ORM',
|
header: 'ORM',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Prisma', description: 'Type-safe ORM' },
|
{ label: 'Prisma', description: 'Type-safe ORM' },
|
||||||
{ label: 'Drizzle', description: 'Lightweight ORM' },
|
{ label: 'Drizzle', description: 'Lightweight ORM' },
|
||||||
@@ -359,12 +365,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which testing framework?',
|
question: 'Which testing framework?',
|
||||||
header: 'Testing',
|
header: 'Testing',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
|
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Which CI provider?',
|
question: 'Which CI provider?',
|
||||||
header: 'CI',
|
header: 'CI',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'GitHub Actions', description: 'Built into GitHub' },
|
{ label: 'GitHub Actions', description: 'Built into GitHub' },
|
||||||
],
|
],
|
||||||
@@ -402,12 +410,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which package manager?',
|
question: 'Which package manager?',
|
||||||
header: 'Package',
|
header: 'Package',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
|
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Which bundler?',
|
question: 'Which bundler?',
|
||||||
header: 'Bundler',
|
header: 'Bundler',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Vite', description: 'Next generation bundler' }],
|
options: [{ label: 'Vite', description: 'Next generation bundler' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -465,6 +475,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which framework?',
|
question: 'Which framework?',
|
||||||
header: 'Framework',
|
header: 'Framework',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'React', description: 'Component library' },
|
{ label: 'React', description: 'Component library' },
|
||||||
{ label: 'Vue', description: 'Progressive framework' },
|
{ label: 'Vue', description: 'Progressive framework' },
|
||||||
@@ -474,6 +485,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which styling?',
|
question: 'Which styling?',
|
||||||
header: 'Styling',
|
header: 'Styling',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Tailwind', description: 'Utility-first CSS' },
|
{ label: 'Tailwind', description: 'Utility-first CSS' },
|
||||||
{ label: 'CSS Modules', description: 'Scoped styles' },
|
{ label: 'CSS Modules', description: 'Scoped styles' },
|
||||||
@@ -500,12 +512,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Create tests?',
|
question: 'Create tests?',
|
||||||
header: 'Tests',
|
header: 'Tests',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: 'Generate test files' }],
|
options: [{ label: 'Yes', description: 'Generate test files' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Add documentation?',
|
question: 'Add documentation?',
|
||||||
header: 'Docs',
|
header: 'Docs',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
|
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -545,12 +559,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which license?',
|
question: 'Which license?',
|
||||||
header: 'License',
|
header: 'License',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'MIT', description: 'Permissive license' }],
|
options: [{ label: 'MIT', description: 'Permissive license' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Include README?',
|
question: 'Include README?',
|
||||||
header: 'README',
|
header: 'README',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: 'Generate README.md' }],
|
options: [{ label: 'Yes', description: 'Generate README.md' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -580,12 +596,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Target Node version?',
|
question: 'Target Node version?',
|
||||||
header: 'Node',
|
header: 'Node',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Node 20', description: 'LTS version' }],
|
options: [{ label: 'Node 20', description: 'LTS version' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Enable strict mode?',
|
question: 'Enable strict mode?',
|
||||||
header: 'Strict',
|
header: 'Strict',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
|
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -727,6 +745,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Should it be async?',
|
question: 'Should it be async?',
|
||||||
header: 'Async',
|
header: 'Async',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Yes', description: 'Use async/await' },
|
{ label: 'Yes', description: 'Use async/await' },
|
||||||
{ label: 'No', description: 'Synchronous hook' },
|
{ label: 'No', description: 'Synchronous hook' },
|
||||||
@@ -773,6 +792,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which styling approach?',
|
question: 'Which styling approach?',
|
||||||
header: 'Style',
|
header: 'Style',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'CSS Modules', description: 'Scoped CSS' },
|
{ label: 'CSS Modules', description: 'Scoped CSS' },
|
||||||
{ label: 'Tailwind', description: 'Utility classes' },
|
{ label: 'Tailwind', description: 'Utility classes' },
|
||||||
@@ -895,6 +915,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Choice Q?',
|
question: 'Choice Q?',
|
||||||
header: 'Choice',
|
header: 'Choice',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Option 1', description: '' }],
|
options: [{ label: 'Option 1', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -952,12 +973,14 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Question 1?',
|
question: 'Question 1?',
|
||||||
header: 'Q1',
|
header: 'Q1',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'A1', description: '' }],
|
options: [{ label: 'A1', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Question 2?',
|
question: 'Question 2?',
|
||||||
header: 'Q2',
|
header: 'Q2',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'A2', description: '' }],
|
options: [{ label: 'A2', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1008,6 +1031,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which option do you prefer?',
|
question: 'Which option do you prefer?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: '' }],
|
options: [{ label: 'Yes', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1036,6 +1060,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Is **this** working?',
|
question: 'Is **this** working?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: '' }],
|
options: [{ label: 'Yes', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1067,6 +1092,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Is **this** working?',
|
question: 'Is **this** working?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: '' }],
|
options: [{ label: 'Yes', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1096,6 +1122,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Run `npm start`?',
|
question: 'Run `npm start`?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Yes', description: '' }],
|
options: [{ label: 'Yes', description: '' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1126,6 +1153,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Choose an option',
|
question: 'Choose an option',
|
||||||
header: 'Context Test',
|
header: 'Context Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: Array.from({ length: 10 }, (_, i) => ({
|
options: Array.from({ length: 10 }, (_, i) => ({
|
||||||
label: `Option ${i + 1}`,
|
label: `Option ${i + 1}`,
|
||||||
description: `Description ${i + 1}`,
|
description: `Description ${i + 1}`,
|
||||||
@@ -1162,6 +1190,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: longQuestion,
|
question: longQuestion,
|
||||||
header: 'Alternate Buffer Test',
|
header: 'Alternate Buffer Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
||||||
multiSelect: false,
|
multiSelect: false,
|
||||||
},
|
},
|
||||||
@@ -1195,6 +1224,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Select your preferred language:',
|
question: 'Select your preferred language:',
|
||||||
header: 'Language',
|
header: 'Language',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'TypeScript', description: '' },
|
{ label: 'TypeScript', description: '' },
|
||||||
{ label: 'JavaScript', description: '' },
|
{ label: 'JavaScript', description: '' },
|
||||||
@@ -1228,6 +1258,7 @@ describe('AskUserDialog', () => {
|
|||||||
{
|
{
|
||||||
question: 'Select your preferred language:',
|
question: 'Select your preferred language:',
|
||||||
header: 'Language',
|
header: 'Language',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'TypeScript', description: '' },
|
{ label: 'TypeScript', description: '' },
|
||||||
{ label: 'JavaScript', description: '' },
|
{ label: 'JavaScript', description: '' },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { act } from 'react';
|
|||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { AskUserDialog } from './AskUserDialog.js';
|
import { AskUserDialog } from './AskUserDialog.js';
|
||||||
import type { Question } from '@google/gemini-cli-core';
|
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
describe('Key Bubbling Regression', () => {
|
describe('Key Bubbling Regression', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -20,6 +20,7 @@ describe('Key Bubbling Regression', () => {
|
|||||||
{
|
{
|
||||||
question: 'Choice Q?',
|
question: 'Choice Q?',
|
||||||
header: 'Choice',
|
header: 'Choice',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Option 1', description: '' },
|
{ label: 'Option 1', description: '' },
|
||||||
{ label: 'Option 2', description: '' },
|
{ label: 'Option 2', description: '' },
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import { TextInput } from './TextInput.js';
|
import { TextInput } from './TextInput.js';
|
||||||
@@ -31,6 +30,27 @@ export interface SearchableListProps<T extends GenericListItem> {
|
|||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
/** Max items to show at once */
|
/** Max items to show at once */
|
||||||
maxItemsToShow?: number;
|
maxItemsToShow?: number;
|
||||||
|
/** Custom item renderer */
|
||||||
|
renderItem?: (
|
||||||
|
item: T,
|
||||||
|
isActive: boolean,
|
||||||
|
labelWidth: number,
|
||||||
|
) => React.JSX.Element;
|
||||||
|
/** Optional custom header element */
|
||||||
|
header?: React.ReactNode;
|
||||||
|
/** Optional custom footer element, can be a function to receive pagination info */
|
||||||
|
footer?:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((pagination: SearchableListPaginationInfo) => React.ReactNode);
|
||||||
|
/** If true, disables client-side filtering. Useful if items are already filtered externally. */
|
||||||
|
disableFiltering?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchableListPaginationInfo {
|
||||||
|
startIndex: number; // 0-indexed
|
||||||
|
endIndex: number; // 0-indexed, exclusive
|
||||||
|
totalVisible: number;
|
||||||
|
totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,19 +64,39 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
initialSearchQuery = '',
|
initialSearchQuery = '',
|
||||||
searchPlaceholder = 'Search...',
|
searchPlaceholder = 'Search...',
|
||||||
maxItemsToShow = 10,
|
maxItemsToShow = 10,
|
||||||
}: SearchableListProps<T>): React.JSX.Element {
|
renderItem,
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
disableFiltering = false,
|
||||||
|
onSearch,
|
||||||
|
}: SearchableListProps<T> & {
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({
|
||||||
items,
|
items,
|
||||||
initialQuery: initialSearchQuery,
|
initialQuery: initialSearchQuery,
|
||||||
|
disableFiltering,
|
||||||
|
onSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
|
||||||
// Reset selection when filtered items change
|
const prevFilteredKeysRef = React.useRef<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveIndex(0);
|
const currentKeys = filteredItems.map((item) => item.key);
|
||||||
setScrollOffset(0);
|
const prevKeys = prevFilteredKeysRef.current;
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
currentKeys.length !== prevKeys.length ||
|
||||||
|
currentKeys.some((key, index) => key !== prevKeys[index]);
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
setScrollOffset(0);
|
||||||
|
prevFilteredKeysRef.current = currentKeys;
|
||||||
|
}
|
||||||
}, [filteredItems]);
|
}, [filteredItems]);
|
||||||
|
|
||||||
// Calculate visible items
|
// Calculate visible items
|
||||||
@@ -113,16 +153,25 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
padding={1}
|
padding={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Title */}
|
||||||
{title && (
|
{title && (
|
||||||
<Box marginBottom={1}>
|
<Box marginX={1}>
|
||||||
<Text bold>{title}</Text>
|
<Text bold color={theme.text.primary}>
|
||||||
|
{'>'} {title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{header && (
|
||||||
|
<Box marginX={1} marginTop={1}>
|
||||||
|
{header}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -132,7 +181,9 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={theme.border.focused}
|
borderColor={theme.border.focused}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginBottom={1}
|
height={3}
|
||||||
|
marginTop={1}
|
||||||
|
width="100%"
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
buffer={searchBuffer}
|
buffer={searchBuffer}
|
||||||
@@ -143,45 +194,85 @@ export function SearchableList<T extends GenericListItem>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{showScrollUp && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary}>▲</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{visibleItems.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<Text color={theme.text.secondary}>No items found.</Text>
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>No items found.</Text>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
visibleItems.map((item, idx) => {
|
visibleItems.map((item, idx) => {
|
||||||
const index = scrollOffset + idx;
|
const index = scrollOffset + idx;
|
||||||
const isActive = index === activeIndex;
|
const isActive = index === activeIndex;
|
||||||
|
|
||||||
|
if (renderItem) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.key}>
|
||||||
|
<Box>{renderItem(item, isActive, maxLabelWidth)}</Box>
|
||||||
|
<Box height={1} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={item.key} flexDirection="row">
|
<React.Fragment key={item.key}>
|
||||||
<Text
|
<Box flexDirection="row" alignItems="flex-start">
|
||||||
color={isActive ? theme.status.success : theme.text.secondary}
|
<Box minWidth={2} flexShrink={0}>
|
||||||
>
|
<Text
|
||||||
{isActive ? '> ' : ' '}
|
color={
|
||||||
</Text>
|
isActive ? theme.status.success : theme.text.secondary
|
||||||
<Box width={maxLabelWidth + 2}>
|
}
|
||||||
<Text
|
>
|
||||||
color={isActive ? theme.status.success : theme.text.primary}
|
{isActive ? '> ' : ' '}
|
||||||
>
|
</Text>
|
||||||
{item.label}
|
</Box>
|
||||||
</Text>
|
<Box width={maxLabelWidth + 2}>
|
||||||
|
<Text
|
||||||
|
bold={isActive}
|
||||||
|
color={
|
||||||
|
isActive ? theme.status.success : theme.text.primary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{item.description && (
|
||||||
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||||
|
{' '}
|
||||||
|
| {item.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{item.description && (
|
<Box height={1} />
|
||||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
</React.Fragment>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{showScrollDown && (
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
<Text color={theme.text.secondary}>▼</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Footer/Scroll Indicators */}
|
{/* Footer */}
|
||||||
{(showScrollUp || showScrollDown) && (
|
{footer && (
|
||||||
<Box marginTop={1} justifyContent="center">
|
<Box marginX={1} marginTop={1}>
|
||||||
<Text color={theme.text.secondary}>
|
{typeof footer === 'function'
|
||||||
{showScrollUp ? '▲ ' : ' '}
|
? footer({
|
||||||
{filteredItems.length} items
|
startIndex: scrollOffset,
|
||||||
{showScrollDown ? ' ▼' : ' '}
|
endIndex: Math.min(
|
||||||
</Text>
|
scrollOffset + maxItemsToShow,
|
||||||
|
filteredItems.length,
|
||||||
|
),
|
||||||
|
totalVisible: filteredItems.length,
|
||||||
|
totalItems: items.length,
|
||||||
|
})
|
||||||
|
: footer}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { ExtensionRegistryView } from './ExtensionRegistryView.js';
|
||||||
|
import {
|
||||||
|
ExtensionRegistryClient,
|
||||||
|
type RegistryExtension,
|
||||||
|
} from '../../../config/extensionRegistryClient.js';
|
||||||
|
import { type ExtensionManager } from '../../../config/extension-manager.js';
|
||||||
|
|
||||||
|
vi.mock('../../../config/extensionRegistryClient.js');
|
||||||
|
|
||||||
|
const mockExtensions = [
|
||||||
|
{
|
||||||
|
id: 'ext-1',
|
||||||
|
extensionName: 'Extension 1',
|
||||||
|
extensionDescription: 'Description 1',
|
||||||
|
repoDescription: 'Repo Description 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ext-2',
|
||||||
|
extensionName: 'Extension 2',
|
||||||
|
extensionDescription: 'Description 2',
|
||||||
|
repoDescription: 'Repo Description 2',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ExtensionRegistryView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render loading state initially', async () => {
|
||||||
|
// Return a promise that doesn't resolve immediately to keep the loading state active
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
const mockExtensionManager = {
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<ExtensionRegistryView
|
||||||
|
extensionManager={mockExtensionManager as unknown as ExtensionManager}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toContain('Loading extensions...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render extensions after fetching', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockResolvedValue(mockExtensions as unknown as RegistryExtension[]);
|
||||||
|
|
||||||
|
const mockExtensionManager = {
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<ExtensionRegistryView
|
||||||
|
extensionManager={mockExtensionManager as unknown as ExtensionManager}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for effect and debounce
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
// Add a small delay for debounce/async logic if needed, though mocking resolved value should be enough if called immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toContain('Extension 1');
|
||||||
|
expect(frame).toContain('Description 1');
|
||||||
|
expect(frame).toContain('Extension 2');
|
||||||
|
expect(frame).toContain('Description 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render error message on fetch failure', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockRejectedValue(new Error('Fetch failed'));
|
||||||
|
|
||||||
|
const mockExtensionManager = {
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<ExtensionRegistryView
|
||||||
|
extensionManager={mockExtensionManager as unknown as ExtensionManager}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toContain('Error loading extensions:');
|
||||||
|
expect(frame).toContain('Fetch failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSelect when an item is selected', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockResolvedValue(mockExtensions as unknown as RegistryExtension[]);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const mockExtensionManager = {
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { stdin } = render(
|
||||||
|
<ExtensionRegistryView
|
||||||
|
onSelect={onSelect}
|
||||||
|
extensionManager={mockExtensionManager as unknown as ExtensionManager}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press Enter to select the first item
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\r');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(mockExtensions[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
|
||||||
|
|
||||||
|
import { SearchableList } from '../shared/SearchableList.js';
|
||||||
|
import type { GenericListItem } from '../../hooks/useFuzzyList.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
|
||||||
|
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||||
|
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
|
||||||
|
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||||
|
import type { ExtensionManager } from '../../../config/extension-manager.js';
|
||||||
|
|
||||||
|
interface ExtensionRegistryViewProps {
|
||||||
|
onSelect?: (extension: RegistryExtension) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
extensionManager: ExtensionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionItem extends GenericListItem {
|
||||||
|
extension: RegistryExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionRegistryView({
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
extensionManager,
|
||||||
|
}: ExtensionRegistryViewProps): React.JSX.Element {
|
||||||
|
const { extensions, loading, error, search } = useExtensionRegistry();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
const { extensionsUpdateState } = useExtensionUpdates(
|
||||||
|
extensionManager,
|
||||||
|
() => 0,
|
||||||
|
config.getEnableExtensionReloading(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const installedExtensions = extensionManager.getExtensions();
|
||||||
|
|
||||||
|
const items: ExtensionItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
extensions.map((ext) => ({
|
||||||
|
key: ext.id,
|
||||||
|
label: ext.extensionName,
|
||||||
|
description: ext.extensionDescription || ext.repoDescription,
|
||||||
|
extension: ext,
|
||||||
|
})),
|
||||||
|
[extensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (item: ExtensionItem) => {
|
||||||
|
onSelect?.(item.extension);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = (
|
||||||
|
item: ExtensionItem,
|
||||||
|
isActive: boolean,
|
||||||
|
_labelWidth: number,
|
||||||
|
) => {
|
||||||
|
const isInstalled = installedExtensions.some(
|
||||||
|
(e) => e.name === item.extension.extensionName,
|
||||||
|
);
|
||||||
|
const updateState = extensionsUpdateState.get(item.extension.extensionName);
|
||||||
|
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" width="100%" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||||
|
<Box width={2} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isActive ? theme.status.success : theme.text.secondary}
|
||||||
|
>
|
||||||
|
{isActive ? '> ' : ' '}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
bold={isActive}
|
||||||
|
color={isActive ? theme.status.success : theme.text.primary}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexShrink={0} marginX={1}>
|
||||||
|
<Text color={theme.text.secondary}>|</Text>
|
||||||
|
</Box>
|
||||||
|
{isInstalled && (
|
||||||
|
<Box marginRight={1} flexShrink={0}>
|
||||||
|
<Text color={theme.status.success}>[Installed]</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{hasUpdate && (
|
||||||
|
<Box marginRight={1} flexShrink={0}>
|
||||||
|
<Text color={theme.status.warning}>[Update available]</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexShrink={1} minWidth={0}>
|
||||||
|
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box flexShrink={0} marginLeft={2} width={8} flexDirection="row">
|
||||||
|
<Text color={theme.status.warning}>⭐</Text>
|
||||||
|
<Text color={isActive ? theme.status.success : theme.text.secondary}>
|
||||||
|
{' '}
|
||||||
|
{item.extension.stars || 0}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
||||||
|
<Box flexShrink={1}>
|
||||||
|
<Text color={theme.text.secondary} wrap="truncate">
|
||||||
|
Browse and search extensions from the registry.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexShrink={0} marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{installedExtensions.length &&
|
||||||
|
`${installedExtensions.length} installed`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = ({
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
totalVisible,
|
||||||
|
}: {
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
totalVisible: number;
|
||||||
|
}) => (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
({startIndex + 1}-{endIndex}) / {totalVisible}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box padding={1}>
|
||||||
|
<Text color={theme.text.secondary}>Loading extensions...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box padding={1} flexDirection="column">
|
||||||
|
<Text color={theme.status.error}>Error loading extensions:</Text>
|
||||||
|
<Text color={theme.text.secondary}>{error}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchableList<ExtensionItem>
|
||||||
|
title="Extensions"
|
||||||
|
items={items}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={onClose || (() => {})}
|
||||||
|
searchPlaceholder="Search extension gallery"
|
||||||
|
renderItem={renderItem}
|
||||||
|
header={header}
|
||||||
|
footer={footer}
|
||||||
|
maxItemsToShow={8}
|
||||||
|
disableFiltering={true}
|
||||||
|
onSearch={search}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook } from '../../test-utils/render.js';
|
||||||
|
import { useExtensionRegistry } from './useExtensionRegistry.js';
|
||||||
|
import {
|
||||||
|
ExtensionRegistryClient,
|
||||||
|
type RegistryExtension,
|
||||||
|
} from '../../config/extensionRegistryClient.js';
|
||||||
|
import { act } from 'react';
|
||||||
|
|
||||||
|
vi.mock('../../config/extensionRegistryClient.js');
|
||||||
|
|
||||||
|
const mockExtensions = [
|
||||||
|
{
|
||||||
|
id: 'ext-1',
|
||||||
|
extensionName: 'Extension 1',
|
||||||
|
extensionDescription: 'Description 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ext-2',
|
||||||
|
extensionName: 'Extension 2',
|
||||||
|
extensionDescription: 'Description 2',
|
||||||
|
},
|
||||||
|
] as RegistryExtension[];
|
||||||
|
|
||||||
|
describe('useExtensionRegistry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch extensions on mount', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockResolvedValue(mockExtensions);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExtensionRegistry());
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
expect(result.current.extensions).toEqual([]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.extensions).toEqual(mockExtensions);
|
||||||
|
expect(
|
||||||
|
ExtensionRegistryClient.prototype.searchExtensions,
|
||||||
|
).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle search with debounce', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockResolvedValue(mockExtensions);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExtensionRegistry());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load done
|
||||||
|
expect(
|
||||||
|
ExtensionRegistryClient.prototype.searchExtensions,
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
act(() => {
|
||||||
|
result.current.search('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not happen immediately due to debounce
|
||||||
|
expect(
|
||||||
|
ExtensionRegistryClient.prototype.searchExtensions,
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve(); // Allow potential async effects to run
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ExtensionRegistryClient.prototype.searchExtensions,
|
||||||
|
).toHaveBeenCalledTimes(2);
|
||||||
|
expect(
|
||||||
|
ExtensionRegistryClient.prototype.searchExtensions,
|
||||||
|
).toHaveBeenCalledWith('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle race conditions by ignoring outdated responses', async () => {
|
||||||
|
// Setup a delayed response for the first query 'a'
|
||||||
|
let resolveA: (value: RegistryExtension[]) => void;
|
||||||
|
const promiseA = new Promise<RegistryExtension[]>((resolve) => {
|
||||||
|
resolveA = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Immediate response for query 'b'
|
||||||
|
const responseB = [mockExtensions[1]];
|
||||||
|
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockImplementation(async (query) => {
|
||||||
|
if (query === 'a') return promiseA;
|
||||||
|
if (query === 'b') return responseB;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExtensionRegistry(''));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve(); // Initial load empty
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search 'a'
|
||||||
|
act(() => {
|
||||||
|
result.current.search('a');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search 'b' immediately after (conceptually, though heavily simplified test here)
|
||||||
|
// Actually, to test race condition:
|
||||||
|
// 1. Trigger search 'a'.
|
||||||
|
// 2. Trigger search 'b'.
|
||||||
|
// 3. 'b' resolves.
|
||||||
|
// 4. 'a' resolves later.
|
||||||
|
// 5. State should match 'b'.
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.search('b');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve(); // 'b' resolves immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.extensions).toEqual(responseB);
|
||||||
|
|
||||||
|
// Now resolve 'a'
|
||||||
|
await act(async () => {
|
||||||
|
resolveA!(mockExtensions);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still be 'b' because 'a' was outdated
|
||||||
|
expect(result.current.extensions).toEqual(responseB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update state if extensions are identical', async () => {
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionRegistryClient.prototype,
|
||||||
|
'searchExtensions',
|
||||||
|
).mockResolvedValue(mockExtensions);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExtensionRegistry());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialExtensions = result.current.extensions;
|
||||||
|
|
||||||
|
// Trigger another search that returns identical content
|
||||||
|
act(() => {
|
||||||
|
result.current.search('test');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reference should be exactly the same
|
||||||
|
expect(result.current.extensions).toBe(initialExtensions);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ExtensionRegistryClient,
|
||||||
|
type RegistryExtension,
|
||||||
|
} from '../../config/extensionRegistryClient.js';
|
||||||
|
|
||||||
|
export interface UseExtensionRegistryResult {
|
||||||
|
extensions: RegistryExtension[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
search: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtensionRegistry(
|
||||||
|
initialQuery = '',
|
||||||
|
): UseExtensionRegistryResult {
|
||||||
|
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const client = useMemo(() => new ExtensionRegistryClient(), []);
|
||||||
|
|
||||||
|
// Ref to track the latest query to avoid race conditions
|
||||||
|
const latestQueryRef = useRef(initialQuery);
|
||||||
|
// Ref for debounce timeout
|
||||||
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
|
||||||
|
const searchExtensions = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const results = await client.searchExtensions(query);
|
||||||
|
// Only update if this is still the latest query
|
||||||
|
if (query === latestQueryRef.current) {
|
||||||
|
// Check if results are different from current extensions
|
||||||
|
setExtensions((prev) => {
|
||||||
|
if (
|
||||||
|
prev.length === results.length &&
|
||||||
|
prev.every((ext, i) => ext.id === results[i].id)
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (query === latestQueryRef.current) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setExtensions([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
latestQueryRef.current = query;
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
void searchExtensions(query);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[searchExtensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
void searchExtensions(initialQuery);
|
||||||
|
return () => {
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [initialQuery, searchExtensions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,14 +13,6 @@ import {
|
|||||||
} from '../components/shared/text-buffer.js';
|
} from '../components/shared/text-buffer.js';
|
||||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
|
|
||||||
interface FzfResult {
|
|
||||||
item: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
score: number;
|
|
||||||
positions?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenericListItem {
|
export interface GenericListItem {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,6 +24,7 @@ export interface UseFuzzyListProps<T extends GenericListItem> {
|
|||||||
items: T[];
|
items: T[];
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
|
disableFiltering?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseFuzzyListResult<T extends GenericListItem> {
|
export interface UseFuzzyListResult<T extends GenericListItem> {
|
||||||
@@ -46,6 +39,7 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
items,
|
items,
|
||||||
initialQuery = '',
|
initialQuery = '',
|
||||||
onSearch,
|
onSearch,
|
||||||
|
disableFiltering = false,
|
||||||
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
}: UseFuzzyListProps<T>): UseFuzzyListResult<T> {
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||||
@@ -54,54 +48,81 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// FZF instance for fuzzy searching
|
// FZF instance for fuzzy searching
|
||||||
const { fzfInstance, searchMap } = useMemo(() => {
|
// FZF instance for fuzzy searching - skip if filtering is disabled
|
||||||
const map = new Map<string, string>();
|
const fzfInstance = useMemo(() => {
|
||||||
const searchItems: string[] = [];
|
if (disableFiltering) return null;
|
||||||
|
return new AsyncFzf(items, {
|
||||||
items.forEach((item) => {
|
|
||||||
searchItems.push(item.label);
|
|
||||||
map.set(item.label.toLowerCase(), item.key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fzf = new AsyncFzf(searchItems, {
|
|
||||||
fuzzy: 'v2',
|
fuzzy: 'v2',
|
||||||
casing: 'case-insensitive',
|
casing: 'case-insensitive',
|
||||||
|
selector: (item: T) => item.label,
|
||||||
});
|
});
|
||||||
return { fzfInstance: fzf, searchMap: map };
|
}, [items, disableFiltering]);
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// Perform search
|
// Perform search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
if (!searchQuery.trim() || !fzfInstance) {
|
if (!searchQuery.trim() || (!fzfInstance && !disableFiltering)) {
|
||||||
setFilteredKeys(items.map((i) => i.key));
|
setFilteredKeys(items.map((i) => i.key));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSearch = async () => {
|
const doSearch = async () => {
|
||||||
const results = await fzfInstance.find(searchQuery);
|
// If filtering is disabled, or no query/fzf, just return all items (or handle external search elsewhere)
|
||||||
|
if (disableFiltering) {
|
||||||
|
onSearch?.(searchQuery);
|
||||||
|
// When filtering is disabled, we assume the items passed in are already filtered
|
||||||
|
// so we set filteredKeys to all items
|
||||||
|
const allKeys = items.map((i) => i.key);
|
||||||
|
setFilteredKeys((prev) => {
|
||||||
|
if (
|
||||||
|
prev.length === allKeys.length &&
|
||||||
|
prev.every((key, index) => key === allKeys[index])
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return allKeys;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!active) return;
|
if (fzfInstance) {
|
||||||
|
const results = await fzfInstance.find(searchQuery);
|
||||||
|
|
||||||
const matchedKeys = new Set<string>();
|
if (!active) return;
|
||||||
results.forEach((res: FzfResult) => {
|
|
||||||
const key = searchMap.get(res.item.toLowerCase());
|
const matchedKeys = results.map((res: { item: T }) => res.item.key);
|
||||||
if (key) matchedKeys.add(key);
|
setFilteredKeys((prev) => {
|
||||||
});
|
if (
|
||||||
setFilteredKeys(Array.from(matchedKeys));
|
prev.length === matchedKeys.length &&
|
||||||
onSearch?.(searchQuery);
|
prev.every((key, index) => key === matchedKeys[index])
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return matchedKeys;
|
||||||
|
});
|
||||||
|
onSearch?.(searchQuery);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void doSearch().catch((error) => {
|
void doSearch().catch((error) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error
|
const allKeys = items.map((i) => i.key);
|
||||||
|
setFilteredKeys((prev) => {
|
||||||
|
if (
|
||||||
|
prev.length === allKeys.length &&
|
||||||
|
prev.every((key, index) => key === allKeys[index])
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return allKeys;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [searchQuery, fzfInstance, searchMap, items, onSearch]);
|
}, [searchQuery, fzfInstance, items, onSearch, disableFiltering]);
|
||||||
|
|
||||||
// Get mainAreaWidth for search buffer viewport from UIState
|
// Get mainAreaWidth for search buffer viewport from UIState
|
||||||
const { mainAreaWidth } = useUIState();
|
const { mainAreaWidth } = useUIState();
|
||||||
@@ -121,9 +142,10 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
|
|
||||||
// Filtered items to display
|
// Filtered items to display
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
|
if (disableFiltering) return items;
|
||||||
if (!searchQuery) return items;
|
if (!searchQuery) return items;
|
||||||
return items.filter((item) => filteredKeys.includes(item.key));
|
return items.filter((item) => filteredKeys.includes(item.key));
|
||||||
}, [items, filteredKeys, searchQuery]);
|
}, [items, filteredKeys, searchQuery, disableFiltering]);
|
||||||
|
|
||||||
// Calculate max label width for alignment
|
// Calculate max label width for alignment
|
||||||
const maxLabelWidth = useMemo(() => {
|
const maxLabelWidth = useMemo(() => {
|
||||||
@@ -133,10 +155,7 @@ export function useFuzzyList<T extends GenericListItem>({
|
|||||||
const labelFull =
|
const labelFull =
|
||||||
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
|
item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : '');
|
||||||
const lWidth = getCachedStringWidth(labelFull);
|
const lWidth = getCachedStringWidth(labelFull);
|
||||||
const dWidth = item.description
|
max = Math.max(max, lWidth);
|
||||||
? getCachedStringWidth(item.description)
|
|
||||||
: 0;
|
|
||||||
max = Math.max(max, lWidth, dWidth);
|
|
||||||
});
|
});
|
||||||
return max;
|
return max;
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config, AuthType } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
|
||||||
debugLogger,
|
debugLogger,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
ExitCodes,
|
ExitCodes,
|
||||||
|
getAuthTypeFromEnv,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { USER_SETTINGS_PATH } from './config/settings.js';
|
import { USER_SETTINGS_PATH } from './config/settings.js';
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
@@ -17,19 +17,6 @@ import { type LoadedSettings } from './config/settings.js';
|
|||||||
import { handleError } from './utils/errors.js';
|
import { handleError } from './utils/errors.js';
|
||||||
import { runExitCleanup } from './utils/cleanup.js';
|
import { runExitCleanup } from './utils/cleanup.js';
|
||||||
|
|
||||||
function getAuthTypeFromEnv(): AuthType | undefined {
|
|
||||||
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
|
|
||||||
return AuthType.LOGIN_WITH_GOOGLE;
|
|
||||||
}
|
|
||||||
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
|
|
||||||
return AuthType.USE_VERTEX_AI;
|
|
||||||
}
|
|
||||||
if (process.env['GEMINI_API_KEY']) {
|
|
||||||
return AuthType.USE_GEMINI;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateNonInteractiveAuth(
|
export async function validateNonInteractiveAuth(
|
||||||
configuredAuthType: AuthType | undefined,
|
configuredAuthType: AuthType | undefined,
|
||||||
useExternalAuth: boolean | undefined,
|
useExternalAuth: boolean | undefined,
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ export enum QuestionType {
|
|||||||
export interface Question {
|
export interface Question {
|
||||||
question: string;
|
question: string;
|
||||||
header: string;
|
header: string;
|
||||||
/** Question type: 'choice' renders selectable options, 'text' renders free-form input, 'yesno' renders a binary Yes/No choice. Defaults to 'choice'. */
|
/** Question type: 'choice' renders selectable options, 'text' renders free-form input, 'yesno' renders a binary Yes/No choice. */
|
||||||
type?: QuestionType;
|
type: QuestionType;
|
||||||
/** Selectable choices. REQUIRED when type='choice' or omitted. IGNORED for 'text' and 'yesno'. */
|
/** Selectable choices. REQUIRED when type='choice'. IGNORED for 'text' and 'yesno'. */
|
||||||
options?: QuestionOption[];
|
options?: QuestionOption[];
|
||||||
/** Allow multiple selections. Only applies when type='choice'. */
|
/** Allow multiple selections. Only applies when type='choice'. */
|
||||||
multiSelect?: boolean;
|
multiSelect?: boolean;
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ export enum AuthType {
|
|||||||
COMPUTE_ADC = 'compute-default-credentials',
|
COMPUTE_ADC = 'compute-default-credentials',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the best authentication type based on environment variables.
|
||||||
|
*
|
||||||
|
* Checks in order:
|
||||||
|
* 1. GOOGLE_GENAI_USE_GCA=true -> LOGIN_WITH_GOOGLE
|
||||||
|
* 2. GOOGLE_GENAI_USE_VERTEXAI=true -> USE_VERTEX_AI
|
||||||
|
* 3. GEMINI_API_KEY -> USE_GEMINI
|
||||||
|
*/
|
||||||
|
export function getAuthTypeFromEnv(): AuthType | undefined {
|
||||||
|
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
|
||||||
|
return AuthType.LOGIN_WITH_GOOGLE;
|
||||||
|
}
|
||||||
|
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
|
||||||
|
return AuthType.USE_VERTEX_AI;
|
||||||
|
}
|
||||||
|
if (process.env['GEMINI_API_KEY']) {
|
||||||
|
return AuthType.USE_GEMINI;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export type ContentGeneratorConfig = {
|
export type ContentGeneratorConfig = {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
vertexai?: boolean;
|
vertexai?: boolean;
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export * from './prompts/mcp-prompts.js';
|
|||||||
export * from './agents/types.js';
|
export * from './agents/types.js';
|
||||||
export * from './agents/agentLoader.js';
|
export * from './agents/agentLoader.js';
|
||||||
export * from './agents/local-executor.js';
|
export * from './agents/local-executor.js';
|
||||||
|
export * from './agents/agent-scheduler.js';
|
||||||
|
|
||||||
// Export specific tool logic
|
// Export specific tool logic
|
||||||
export * from './tools/read-file.js';
|
export * from './tools/read-file.js';
|
||||||
@@ -191,3 +192,6 @@ export * from './agents/types.js';
|
|||||||
// Export stdio utils
|
// Export stdio utils
|
||||||
export * from './utils/stdio.js';
|
export * from './utils/stdio.js';
|
||||||
export * from './utils/terminal.js';
|
export * from './utils/terminal.js';
|
||||||
|
|
||||||
|
// Export types from @google/genai
|
||||||
|
export type { Content, Part, FunctionCall } from '@google/genai';
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ describe('AskUserTool', () => {
|
|||||||
const questions = Array(5).fill({
|
const questions = Array(5).fill({
|
||||||
question: 'Test?',
|
question: 'Test?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'A', description: 'A' },
|
{ label: 'A', description: 'A' },
|
||||||
{ label: 'B', description: 'B' },
|
{ label: 'B', description: 'B' },
|
||||||
@@ -156,7 +157,13 @@ describe('AskUserTool', () => {
|
|||||||
|
|
||||||
it('should return error if header exceeds max length', () => {
|
it('should return error if header exceeds max length', () => {
|
||||||
const result = tool.validateToolParams({
|
const result = tool.validateToolParams({
|
||||||
questions: [{ question: 'Test?', header: 'This is way too long' }],
|
questions: [
|
||||||
|
{
|
||||||
|
question: 'Test?',
|
||||||
|
header: 'This is way too long',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(result).toContain('must NOT have more than 16 characters');
|
expect(result).toContain('must NOT have more than 16 characters');
|
||||||
});
|
});
|
||||||
@@ -167,6 +174,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Test?',
|
question: 'Test?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [{ label: 'A', description: 'A' }],
|
options: [{ label: 'A', description: 'A' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -182,6 +190,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Test?',
|
question: 'Test?',
|
||||||
header: 'Test',
|
header: 'Test',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'A', description: 'A' },
|
{ label: 'A', description: 'A' },
|
||||||
{ label: 'B', description: 'B' },
|
{ label: 'B', description: 'B' },
|
||||||
@@ -201,6 +210,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which approach?',
|
question: 'Which approach?',
|
||||||
header: 'Approach',
|
header: 'Approach',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'A', description: 'Option A' },
|
{ label: 'A', description: 'Option A' },
|
||||||
{ label: 'B', description: 'Option B' },
|
{ label: 'B', description: 'Option B' },
|
||||||
@@ -224,18 +234,16 @@ describe('AskUserTool', () => {
|
|||||||
expect(result).toContain("type='choice' requires 'options'");
|
expect(result).toContain("type='choice' requires 'options'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error if type is omitted and options missing (defaults to choice)', () => {
|
it('should return error if type is missing', () => {
|
||||||
const result = tool.validateToolParams({
|
const result = tool.validateToolParams({
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
question: 'Pick one?',
|
question: 'Pick one?',
|
||||||
header: 'Choice',
|
header: 'Choice',
|
||||||
// type omitted, defaults to 'choice'
|
} as unknown as Question,
|
||||||
// options missing
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(result).toContain("type='choice' requires 'options'");
|
expect(result).toContain("must have required property 'type'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept text type without options', () => {
|
it('should accept text type without options', () => {
|
||||||
@@ -288,6 +296,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Pick one?',
|
question: 'Pick one?',
|
||||||
header: 'Choice',
|
header: 'Choice',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: '', description: 'Empty label' },
|
{ label: '', description: 'Empty label' },
|
||||||
{ label: 'B', description: 'Option B' },
|
{ label: 'B', description: 'Option B' },
|
||||||
@@ -304,6 +313,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Pick one?',
|
question: 'Pick one?',
|
||||||
header: 'Choice',
|
header: 'Choice',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'A' } as { label: string; description: string },
|
{ label: 'A' } as { label: string; description: string },
|
||||||
{ label: 'B', description: 'Option B' },
|
{ label: 'B', description: 'Option B' },
|
||||||
@@ -318,7 +328,13 @@ describe('AskUserTool', () => {
|
|||||||
describe('validateBuildAndExecute', () => {
|
describe('validateBuildAndExecute', () => {
|
||||||
it('should hide validation errors from returnDisplay', async () => {
|
it('should hide validation errors from returnDisplay', async () => {
|
||||||
const params = {
|
const params = {
|
||||||
questions: [{ question: 'Test?', header: 'This is way too long' }],
|
questions: [
|
||||||
|
{
|
||||||
|
question: 'Test?',
|
||||||
|
header: 'This is way too long',
|
||||||
|
type: QuestionType.TEXT,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await tool.validateBuildAndExecute(
|
const result = await tool.validateBuildAndExecute(
|
||||||
@@ -337,7 +353,9 @@ describe('AskUserTool', () => {
|
|||||||
.mockReturnValue(null);
|
.mockReturnValue(null);
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
questions: [{ question: 'Valid?', header: 'Valid' }],
|
questions: [
|
||||||
|
{ question: 'Valid?', header: 'Valid', type: QuestionType.TEXT },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvocation = {
|
const mockInvocation = {
|
||||||
@@ -366,10 +384,11 @@ describe('AskUserTool', () => {
|
|||||||
|
|
||||||
describe('shouldConfirmExecute', () => {
|
describe('shouldConfirmExecute', () => {
|
||||||
it('should return confirmation details with normalized questions', async () => {
|
it('should return confirmation details with normalized questions', async () => {
|
||||||
const questions = [
|
const questions: Question[] = [
|
||||||
{
|
{
|
||||||
question: 'How should we proceed with this task?',
|
question: 'How should we proceed with this task?',
|
||||||
header: 'Approach',
|
header: 'Approach',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: 'Quick fix (Recommended)',
|
label: 'Quick fix (Recommended)',
|
||||||
@@ -394,12 +413,7 @@ describe('AskUserTool', () => {
|
|||||||
expect(details).not.toBe(false);
|
expect(details).not.toBe(false);
|
||||||
if (details && details.type === 'ask_user') {
|
if (details && details.type === 'ask_user') {
|
||||||
expect(details.title).toBe('Ask User');
|
expect(details.title).toBe('Ask User');
|
||||||
expect(details.questions).toEqual(
|
expect(details.questions).toEqual(questions);
|
||||||
questions.map((q) => ({
|
|
||||||
...q,
|
|
||||||
type: QuestionType.CHOICE,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
expect(typeof details.onConfirm).toBe('function');
|
expect(typeof details.onConfirm).toBe('function');
|
||||||
} else {
|
} else {
|
||||||
// Type guard for TypeScript
|
// Type guard for TypeScript
|
||||||
@@ -407,11 +421,12 @@ describe('AskUserTool', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize question type to CHOICE when omitted', async () => {
|
it('should use provided question type', async () => {
|
||||||
const questions = [
|
const questions: Question[] = [
|
||||||
{
|
{
|
||||||
question: 'Which approach?',
|
question: 'Which approach?',
|
||||||
header: 'Approach',
|
header: 'Approach',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Option A', description: 'First option' },
|
{ label: 'Option A', description: 'First option' },
|
||||||
{ label: 'Option B', description: 'Second option' },
|
{ label: 'Option B', description: 'Second option' },
|
||||||
@@ -432,10 +447,11 @@ describe('AskUserTool', () => {
|
|||||||
|
|
||||||
describe('execute', () => {
|
describe('execute', () => {
|
||||||
it('should return user answers after confirmation', async () => {
|
it('should return user answers after confirmation', async () => {
|
||||||
const questions = [
|
const questions: Question[] = [
|
||||||
{
|
{
|
||||||
question: 'How should we proceed with this task?',
|
question: 'How should we proceed with this task?',
|
||||||
header: 'Approach',
|
header: 'Approach',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: 'Quick fix (Recommended)',
|
label: 'Quick fix (Recommended)',
|
||||||
@@ -484,10 +500,11 @@ describe('AskUserTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display message when user submits without answering', async () => {
|
it('should display message when user submits without answering', async () => {
|
||||||
const questions = [
|
const questions: Question[] = [
|
||||||
{
|
{
|
||||||
question: 'Which approach?',
|
question: 'Which approach?',
|
||||||
header: 'Approach',
|
header: 'Approach',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Option A', description: 'First option' },
|
{ label: 'Option A', description: 'First option' },
|
||||||
{ label: 'Option B', description: 'Second option' },
|
{ label: 'Option B', description: 'Second option' },
|
||||||
@@ -528,6 +545,7 @@ describe('AskUserTool', () => {
|
|||||||
{
|
{
|
||||||
question: 'Which sections of the documentation should be updated?',
|
question: 'Which sections of the documentation should be updated?',
|
||||||
header: 'Docs',
|
header: 'Docs',
|
||||||
|
type: QuestionType.CHOICE,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: 'User Guide',
|
label: 'User Guide',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class AskUserTool extends BaseDeclarativeTool<
|
|||||||
maxItems: 4,
|
maxItems: 4,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['question', 'header'],
|
required: ['question', 'header', 'type'],
|
||||||
properties: {
|
properties: {
|
||||||
question: {
|
question: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -111,7 +111,7 @@ export class AskUserTool extends BaseDeclarativeTool<
|
|||||||
|
|
||||||
for (let i = 0; i < params.questions.length; i++) {
|
for (let i = 0; i < params.questions.length; i++) {
|
||||||
const q = params.questions[i];
|
const q = params.questions[i];
|
||||||
const questionType = q.type ?? QuestionType.CHOICE;
|
const questionType = q.type;
|
||||||
|
|
||||||
// Validate that 'choice' type has options
|
// Validate that 'choice' type has options
|
||||||
if (questionType === QuestionType.CHOICE) {
|
if (questionType === QuestionType.CHOICE) {
|
||||||
@@ -186,7 +186,7 @@ export class AskUserInvocation extends BaseToolInvocation<
|
|||||||
): Promise<ToolAskUserConfirmationDetails | false> {
|
): Promise<ToolAskUserConfirmationDetails | false> {
|
||||||
const normalizedQuestions = this.params.questions.map((q) => ({
|
const normalizedQuestions = this.params.questions.map((q) => ({
|
||||||
...q,
|
...q,
|
||||||
type: q.type ?? QuestionType.CHOICE,
|
type: q.type,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -210,9 +210,7 @@ export class AskUserInvocation extends BaseToolInvocation<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||||
const questionTypes = this.params.questions.map(
|
const questionTypes = this.params.questions.map((q) => q.type);
|
||||||
(q) => q.type ?? QuestionType.CHOICE,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) {
|
if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -120,18 +120,18 @@ describe('isHeadlessMode', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if -y or --yolo is in process.argv as a fallback', () => {
|
it('should return false if -y or --yolo is in process.argv as a fallback', () => {
|
||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
process.argv = ['node', 'index.js', '-y'];
|
process.argv = ['node', 'index.js', '-y'];
|
||||||
try {
|
try {
|
||||||
expect(isHeadlessMode()).toBe(true);
|
expect(isHeadlessMode()).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
process.argv = originalArgv;
|
process.argv = originalArgv;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.argv = ['node', 'index.js', '--yolo'];
|
process.argv = ['node', 'index.js', '--yolo'];
|
||||||
try {
|
try {
|
||||||
expect(isHeadlessMode()).toBe(true);
|
expect(isHeadlessMode()).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
process.argv = originalArgv;
|
process.argv = originalArgv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ export function isHeadlessMode(options?: HeadlessModeOptions): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check process.argv for flags that imply headless or auto-approve mode.
|
// Fallback: check process.argv for flags that imply headless mode.
|
||||||
return process.argv.some(
|
return process.argv.some((arg) => arg === '-p' || arg === '--prompt');
|
||||||
(arg) =>
|
|
||||||
arg === '-p' || arg === '--prompt' || arg === '-y' || arg === '--yolo',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# @google/gemini-cli-sdk
|
||||||
|
|
||||||
|
The Gemini CLI SDK provides a programmatic interface to interact with Gemini
|
||||||
|
models and tools.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @google/gemini-cli-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GeminiCliAgent } from '@google/gemini-cli-sdk';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
// Stream responses from the agent
|
||||||
|
const stream = agent.sendStream('Why is the sky blue?', signal);
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.type === 'content') {
|
||||||
|
process.stdout.write(chunk.value.text || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# `Gemini CLI SDK`
|
||||||
|
|
||||||
|
# `Examples`
|
||||||
|
|
||||||
|
## `Simple Example`
|
||||||
|
|
||||||
|
Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and
|
||||||
|
user settings.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent } from '@google/gemini-cli-sdk';
|
||||||
|
|
||||||
|
const simpleAgent = new GeminiCliAgent({
|
||||||
|
cwd: '/path/to/some/dir',
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const chunk of simpleAgent.sendStream(
|
||||||
|
'what does this project do?',
|
||||||
|
)) {
|
||||||
|
console.log(chunk); // equivalent to JSON streaming chunks (probably?) for now
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Model receives call containing "what does this project do?" text.
|
||||||
|
|
||||||
|
## `System Instructions`
|
||||||
|
|
||||||
|
System instructions can be provided by a static string OR dynamically via a
|
||||||
|
function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent } from "@google/gemini-cli-sdk";
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: "This is a static string instruction"; // this is valid
|
||||||
|
instructions: (ctx) => `The current time is ${new Date().toISOString()} in session ${ctx.sessionId}.`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Static string instructions show up where GEMINI.md content normally would in
|
||||||
|
model call
|
||||||
|
- Dynamic instructions show up and contain dynamic content.
|
||||||
|
|
||||||
|
## `Custom Tools`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk";
|
||||||
|
|
||||||
|
const addTool = tool({
|
||||||
|
name: 'add',
|
||||||
|
description: 'add two numbers',
|
||||||
|
inputSchema: z.object({
|
||||||
|
a: z.number().describe('first number to add'),
|
||||||
|
b: z.number().describe('second number to add'),
|
||||||
|
}),
|
||||||
|
}, (({a, b}) => ({result: a + b}),);
|
||||||
|
|
||||||
|
const toolAgent = new GeminiCliAgent({
|
||||||
|
tools: [addTool],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await toolAgent.send("what is 23 + 79?");
|
||||||
|
console.log(result.text);
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Model receives tool definition in prompt
|
||||||
|
- Model receives tool response after returning tool
|
||||||
|
|
||||||
|
## `Custom Hooks`
|
||||||
|
|
||||||
|
SDK users can provide programmatic custom hooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, hook, z } from '@google/gemini-cli-sdk';
|
||||||
|
import { reformat } from './reformat.js';
|
||||||
|
|
||||||
|
const myHook = hook(
|
||||||
|
{
|
||||||
|
event: 'AfterTool',
|
||||||
|
name: 'reformat',
|
||||||
|
matcher: 'write_file',
|
||||||
|
},
|
||||||
|
(hook, ctx) => {
|
||||||
|
const filePath = hook.toolInput.path;
|
||||||
|
|
||||||
|
// void return is a no-op
|
||||||
|
if (!filePath.endsWith('.ts')) return;
|
||||||
|
|
||||||
|
// ctx.fs gives us a filesystem interface that obeys Gemini CLI permissions/sandbox
|
||||||
|
const reformatted = await reformat(await ctx.fs.read(filePath));
|
||||||
|
await ctx.fs.write(filePath, reformatted);
|
||||||
|
|
||||||
|
// hooks return a payload instructing the agent how to proceed
|
||||||
|
return {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
additionalContext: `Reformatted file ${filePath}, read again before modifying further.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK Hooks can also run as standalone scripts to implement userland "command"
|
||||||
|
style hooks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { hook } from "@google/gemini-cli-sdk";
|
||||||
|
|
||||||
|
// define a hook as above
|
||||||
|
const myHook = hook({...}, (hook) => {...});
|
||||||
|
// calling runAsCommand parses stdin, calls action, uses appropriate exit code
|
||||||
|
// with output, but you get nice strong typings to guide your impl
|
||||||
|
myHook.runAsCommand();
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation (these are probably hardest to validate):
|
||||||
|
|
||||||
|
- Test each type of hook and check that model api receives injected content
|
||||||
|
- Check global halt scenarios
|
||||||
|
- Check specific return types for each type of hook
|
||||||
|
|
||||||
|
## `Custom Skills`
|
||||||
|
|
||||||
|
Custom skills can be referenced by individual directories or by "skill roots"
|
||||||
|
(directories containing many skills).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-cli-sdk';
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
skills: [skillDir('/path/to/single/skill'), skillRoot('/path/to/skills/dir')],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE:** I would like to support fully in-memory skills (including reference
|
||||||
|
files); however, it seems like that would currently require a pretty significant
|
||||||
|
refactor so we'll focus on filesystem skills for now. In an ideal future state,
|
||||||
|
we could do something like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, skill } from '@google/gemini-cli-sdk';
|
||||||
|
|
||||||
|
const mySkill = skill({
|
||||||
|
name: 'my-skill',
|
||||||
|
description: 'description of when my skill should be used',
|
||||||
|
content: 'This is the SKILL.md content',
|
||||||
|
// it can also be a function
|
||||||
|
content: (ctx) => `This is dynamic content.`,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Subagents`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, subagent } from "@google/gemini-cli";
|
||||||
|
|
||||||
|
const mySubagent = subagent({
|
||||||
|
name: "my-subagent",
|
||||||
|
description: "when the subagent should be used",
|
||||||
|
|
||||||
|
// simple prompt agent with static string or dynamic string
|
||||||
|
instructions: "the instructions",
|
||||||
|
instructions (prompt, ctx) => `can also be dynamic with context`,
|
||||||
|
|
||||||
|
// OR (in an ideal world)...
|
||||||
|
|
||||||
|
// pass a full standalone agent
|
||||||
|
agent: new GeminiCliAgent(...);
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
subagents: [mySubagent]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Extensions`
|
||||||
|
|
||||||
|
Potentially the most important feature of the Gemini CLI SDK is support for
|
||||||
|
extensions, which modularly encapsulate all of the primitives listed above:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent, extension } from "@google/gemini-cli-sdk";
|
||||||
|
|
||||||
|
const myExtension = extension({
|
||||||
|
name: "my-extension",
|
||||||
|
description: "...",
|
||||||
|
instructions: "THESE ARE CONCATENATED WITH OTHER AGENT
|
||||||
|
INSTRUCTIONS",
|
||||||
|
tools: [...],
|
||||||
|
skills: [...],
|
||||||
|
hooks: [...],
|
||||||
|
subagents: [...],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `ACP Mode`
|
||||||
|
|
||||||
|
The SDK will include a wrapper utility to interact with the agent via ACP
|
||||||
|
instead of the SDK's natural API.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { GeminiCliAgent } from "@google/gemini-cli-sdk";
|
||||||
|
import { GeminiCliAcpServer } from "@google/gemini-cli-sdk/acp";
|
||||||
|
|
||||||
|
const server = new GeminiCliAcpServer(new GeminiCliAgent({...}));
|
||||||
|
server.start(); // calling start runs a stdio ACP server
|
||||||
|
|
||||||
|
const client = server.connect({
|
||||||
|
onMessage: (message) => { /* updates etc received here */ },
|
||||||
|
});
|
||||||
|
client.send({...clientMessage}); // e.g. a "session/prompt" message
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Approvals / Policies`
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
# `Implementation Guidance`
|
||||||
|
|
||||||
|
## `Session Context`
|
||||||
|
|
||||||
|
Whenever executing a tool, hook, command, or skill, a SessionContext object
|
||||||
|
should be passed as an additional argument after the arguments/payload. The
|
||||||
|
interface should look something like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SessionContext {
|
||||||
|
// translations of existing common hook payload info
|
||||||
|
sessionId: string;
|
||||||
|
transcript: Message[];
|
||||||
|
cwd: string;
|
||||||
|
timestamp: string;
|
||||||
|
|
||||||
|
// helpers to access files and run shell commands while adhering to policies/validation
|
||||||
|
fs: AgentFilesystem;
|
||||||
|
shell: AgentShell;
|
||||||
|
// the agent itself is passed as context
|
||||||
|
agent: GeminiCliAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFilesystem {
|
||||||
|
readFile(path: string): Promise<string | null>
|
||||||
|
writeFile(path: string, content: string): Promise<void>
|
||||||
|
// consider others including delete, globbing, etc but read/write are bare minimum}
|
||||||
|
|
||||||
|
export interface AgentShell {
|
||||||
|
// simple promise-based execution that blocks until complete
|
||||||
|
exec(cmd: string, options?: AgentShellOptions): Promise<{exitCode: number, output: string, stdout: string, stderr: string}>
|
||||||
|
start(cmd: string, options?: AgentShellOptions): AgentShellProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentShellOptions {
|
||||||
|
env?: Record<string,string>;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentShellProcess {
|
||||||
|
// figure out how to have a streaming shell process here that supports stdin too
|
||||||
|
// investigate how Gemini CLI already does this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# `Notes`
|
||||||
|
|
||||||
|
- To validate the SDK, it would be useful to have a robust way to mock the
|
||||||
|
underlying model API so that the tests could be closer to end-to-end but still
|
||||||
|
deterministic.
|
||||||
|
- Need to work in both Gemini-CLI-triggered approvals and optional
|
||||||
|
developer-initiated user prompts / HITL stuff.
|
||||||
|
- Need to think about how subagents inherit message context \- e.g. do they have
|
||||||
|
the same session id?
|
||||||
|
- Presumably the transcript is kept updated in memory and also persisted to disk
|
||||||
|
by default?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GeminiCliAgent, tool, z } from '../src/index.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const getContextTool = tool(
|
||||||
|
{
|
||||||
|
name: 'get_context',
|
||||||
|
description: 'Get information about the current session context.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async (_params, context) => {
|
||||||
|
if (!context) {
|
||||||
|
return { error: 'Context not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Session Context Accessed:');
|
||||||
|
console.log(`- Session ID: ${context.sessionId}`);
|
||||||
|
console.log(`- CWD: ${context.cwd}`);
|
||||||
|
console.log(`- Timestamp: ${context.timestamp}`);
|
||||||
|
|
||||||
|
let fileContent = null;
|
||||||
|
try {
|
||||||
|
// Try to read a file (e.g., package.json in the CWD)
|
||||||
|
// Note: This relies on the agent running in a directory with package.json
|
||||||
|
fileContent = await context.fs.readFile('package.json');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`- Could not read package.json: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shellOutput = null;
|
||||||
|
try {
|
||||||
|
// Try to run a simple shell command
|
||||||
|
const result = await context.shell.exec('echo "Hello from SDK Shell"');
|
||||||
|
shellOutput = result.output.trim();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`- Could not run shell command: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: context.sessionId,
|
||||||
|
cwd: context.cwd,
|
||||||
|
hasFsAccess: !!context.fs,
|
||||||
|
hasShellAccess: !!context.shell,
|
||||||
|
packageJsonExists: !!fileContent,
|
||||||
|
shellEcho: shellOutput,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions:
|
||||||
|
'You are a helpful assistant. Use the get_context tool to tell me about my environment.',
|
||||||
|
tools: [getContextTool],
|
||||||
|
// Set CWD to the package root so package.json exists
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Sending prompt: 'What is my current session context?'");
|
||||||
|
for await (const chunk of agent.sendStream(
|
||||||
|
'What is my current session context?',
|
||||||
|
)) {
|
||||||
|
if (chunk.type === 'content') {
|
||||||
|
process.stdout.write(chunk.value || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GeminiCliAgent, tool, z } from '../src/index.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const myTool = tool(
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add two numbers.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
a: z.number().describe('the first number'),
|
||||||
|
b: z.number().describe('the second number'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async ({ a, b }) => {
|
||||||
|
console.log(`Tool 'add' called with a=${a}, b=${b}`);
|
||||||
|
return { result: a + b };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'Make sure to always talk like a pirate.',
|
||||||
|
tools: [myTool],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Sending prompt: 'add 5 + 6'");
|
||||||
|
for await (const chunk of agent.sendStream(
|
||||||
|
'add 5 + 6 and tell me a story involving the result',
|
||||||
|
)) {
|
||||||
|
console.log(JSON.stringify(chunk, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './src/index.js';
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@google/gemini-cli-sdk",
|
||||||
|
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||||
|
"description": "Gemini CLI SDK",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node ../../scripts/build_package.js",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:ci": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@google/gemini-cli-core": "file:../core",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.23.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
type ConfigParameters,
|
||||||
|
AuthType,
|
||||||
|
PREVIEW_GEMINI_MODEL_AUTO,
|
||||||
|
GeminiEventType,
|
||||||
|
type ToolCallRequestInfo,
|
||||||
|
type ServerGeminiStreamEvent,
|
||||||
|
type GeminiClient,
|
||||||
|
type Content,
|
||||||
|
scheduleAgentTools,
|
||||||
|
getAuthTypeFromEnv,
|
||||||
|
type ToolRegistry,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
import { type Tool, SdkTool } from './tool.js';
|
||||||
|
import { SdkAgentFilesystem } from './fs.js';
|
||||||
|
import { SdkAgentShell } from './shell.js';
|
||||||
|
import type { SessionContext } from './types.js';
|
||||||
|
|
||||||
|
export interface GeminiCliAgentOptions {
|
||||||
|
instructions: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
tools?: Array<Tool<any>>;
|
||||||
|
model?: string;
|
||||||
|
cwd?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
recordResponses?: string;
|
||||||
|
fakeResponses?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiCliAgent {
|
||||||
|
private config: Config;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private tools: Array<Tool<any>>;
|
||||||
|
|
||||||
|
constructor(options: GeminiCliAgentOptions) {
|
||||||
|
const cwd = options.cwd || process.cwd();
|
||||||
|
this.tools = options.tools || [];
|
||||||
|
|
||||||
|
const configParams: ConfigParameters = {
|
||||||
|
sessionId: `sdk-${Date.now()}`,
|
||||||
|
targetDir: cwd,
|
||||||
|
cwd,
|
||||||
|
debugMode: options.debug ?? false,
|
||||||
|
model: options.model || PREVIEW_GEMINI_MODEL_AUTO,
|
||||||
|
userMemory: options.instructions,
|
||||||
|
// Minimal config
|
||||||
|
enableHooks: false,
|
||||||
|
mcpEnabled: false,
|
||||||
|
extensionsEnabled: false,
|
||||||
|
recordResponses: options.recordResponses,
|
||||||
|
fakeResponses: options.fakeResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.config = new Config(configParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *sendStream(
|
||||||
|
prompt: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||||
|
// Lazy initialization of auth and client
|
||||||
|
if (!this.config.getContentGenerator()) {
|
||||||
|
const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC;
|
||||||
|
|
||||||
|
await this.config.refreshAuth(authType);
|
||||||
|
await this.config.initialize();
|
||||||
|
|
||||||
|
// Register tools now that registry exists
|
||||||
|
const registry = this.config.getToolRegistry();
|
||||||
|
const messageBus = this.config.getMessageBus();
|
||||||
|
|
||||||
|
for (const toolDef of this.tools) {
|
||||||
|
const sdkTool = new SdkTool(toolDef, messageBus, this);
|
||||||
|
registry.registerTool(sdkTool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = this.config.getGeminiClient();
|
||||||
|
const abortSignal = signal ?? new AbortController().signal;
|
||||||
|
const sessionId = this.config.getSessionId();
|
||||||
|
|
||||||
|
const fs = new SdkAgentFilesystem(this.config);
|
||||||
|
const shell = new SdkAgentShell(this.config);
|
||||||
|
|
||||||
|
let request: Parameters<GeminiClient['sendMessageStream']>[0] = [
|
||||||
|
{ text: prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// sendMessageStream returns AsyncGenerator<ServerGeminiStreamEvent, Turn>
|
||||||
|
const stream = client.sendMessageStream(request, abortSignal, sessionId);
|
||||||
|
|
||||||
|
const toolCallsToSchedule: ToolCallRequestInfo[] = [];
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
yield event;
|
||||||
|
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
|
const toolCall = event.value;
|
||||||
|
let args = toolCall.args;
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
args = JSON.parse(args);
|
||||||
|
}
|
||||||
|
toolCallsToSchedule.push({
|
||||||
|
...toolCall,
|
||||||
|
args,
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCallsToSchedule.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare SessionContext
|
||||||
|
const transcript: Content[] = client.getHistory();
|
||||||
|
const context: SessionContext = {
|
||||||
|
sessionId,
|
||||||
|
transcript,
|
||||||
|
cwd: this.config.getWorkingDir(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
fs,
|
||||||
|
shell,
|
||||||
|
agent: this,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a scoped registry for this turn to bind context safely
|
||||||
|
const originalRegistry = this.config.getToolRegistry();
|
||||||
|
const scopedRegistry: ToolRegistry = Object.create(originalRegistry);
|
||||||
|
scopedRegistry.getTool = (name: string) => {
|
||||||
|
const tool = originalRegistry.getTool(name);
|
||||||
|
if (tool instanceof SdkTool) {
|
||||||
|
return tool.bindContext(context);
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedCalls = await scheduleAgentTools(
|
||||||
|
this.config,
|
||||||
|
toolCallsToSchedule,
|
||||||
|
{
|
||||||
|
schedulerId: sessionId,
|
||||||
|
toolRegistry: scopedRegistry,
|
||||||
|
signal: abortSignal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const functionResponses = completedCalls.flatMap(
|
||||||
|
(call) => call.response.responseParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
request = functionResponses as unknown as Parameters<
|
||||||
|
GeminiClient['sendMessageStream']
|
||||||
|
>[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config as CoreConfig } from '@google/gemini-cli-core';
|
||||||
|
import type { AgentFilesystem } from './types.js';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
export class SdkAgentFilesystem implements AgentFilesystem {
|
||||||
|
constructor(private readonly config: CoreConfig) {}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string | null> {
|
||||||
|
const error = this.config.validatePathAccess(path, 'read');
|
||||||
|
if (error) {
|
||||||
|
// For now, if access is denied, we can either throw or return null.
|
||||||
|
// Returning null makes sense for "file not found or readable".
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fs.readFile(path, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
const error = this.config.validatePathAccess(path, 'write');
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
await fs.writeFile(path, content, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './agent.js';
|
||||||
|
export * from './tool.js';
|
||||||
|
export * from './types.js';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config as CoreConfig } from '@google/gemini-cli-core';
|
||||||
|
import { ShellExecutionService, ShellTool } from '@google/gemini-cli-core';
|
||||||
|
import type {
|
||||||
|
AgentShell,
|
||||||
|
AgentShellResult,
|
||||||
|
AgentShellOptions,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export class SdkAgentShell implements AgentShell {
|
||||||
|
constructor(private readonly config: CoreConfig) {}
|
||||||
|
|
||||||
|
async exec(
|
||||||
|
command: string,
|
||||||
|
options?: AgentShellOptions,
|
||||||
|
): Promise<AgentShellResult> {
|
||||||
|
const cwd = options?.cwd || this.config.getWorkingDir();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
// Use ShellTool to check policy
|
||||||
|
const shellTool = new ShellTool(this.config, this.config.getMessageBus());
|
||||||
|
try {
|
||||||
|
const invocation = shellTool.build({
|
||||||
|
command,
|
||||||
|
dir_path: cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmation = await invocation.shouldConfirmExecute(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
if (confirmation) {
|
||||||
|
throw new Error(
|
||||||
|
'Command execution requires confirmation but no interactive session is available.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
output: '',
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 1,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await ShellExecutionService.execute(
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
() => {}, // No-op output event handler for now
|
||||||
|
abortController.signal,
|
||||||
|
false, // shouldUseNodePty: false for headless execution
|
||||||
|
this.config.getShellExecutionConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await handle.result;
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: result.output,
|
||||||
|
stdout: result.output, // ShellExecutionService combines stdout/stderr usually
|
||||||
|
stderr: '', // ShellExecutionService currently combines, so stderr is empty or mixed
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GeminiCliAgent } from './agent.js';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { tool, ModelVisibleError } from './tool.js';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Set this to true locally when you need to update snapshots
|
||||||
|
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
|
||||||
|
|
||||||
|
const getGoldenPath = (name: string) =>
|
||||||
|
path.resolve(__dirname, '../test-data', `${name}.json`);
|
||||||
|
|
||||||
|
describe('GeminiCliAgent Tool Integration', () => {
|
||||||
|
it('handles tool execution success', async () => {
|
||||||
|
const goldenFile = getGoldenPath('tool-success');
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
// If recording, use real model + record path.
|
||||||
|
// If testing, use auto model + fake path.
|
||||||
|
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||||
|
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||||
|
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||||
|
tools: [
|
||||||
|
tool(
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Adds two numbers',
|
||||||
|
inputSchema: z.object({ a: z.number(), b: z.number() }),
|
||||||
|
},
|
||||||
|
async ({ a, b }) => a + b,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const stream = agent.sendStream('What is 5 + 3?');
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEvents = events.filter((e) => e.type === 'content');
|
||||||
|
const responseText = textEvents
|
||||||
|
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
expect(responseText).toContain('8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ModelVisibleError correctly', async () => {
|
||||||
|
const goldenFile = getGoldenPath('tool-error-recovery');
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||||
|
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||||
|
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||||
|
tools: [
|
||||||
|
tool(
|
||||||
|
{
|
||||||
|
name: 'failVisible',
|
||||||
|
description: 'Fails with a visible error if input is "fail"',
|
||||||
|
inputSchema: z.object({ input: z.string() }),
|
||||||
|
},
|
||||||
|
async ({ input }) => {
|
||||||
|
if (input === 'fail') {
|
||||||
|
throw new ModelVisibleError('Tool failed visibly');
|
||||||
|
}
|
||||||
|
return 'Success';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
// Force the model to trigger the error first, then hopefully recover or at least acknowledge it.
|
||||||
|
// The prompt is crafted to make the model try 'fail' first.
|
||||||
|
const stream = agent.sendStream(
|
||||||
|
'Call the tool with "fail". If it fails, tell me the error message.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEvents = events.filter((e) => e.type === 'content');
|
||||||
|
const responseText = textEvents
|
||||||
|
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// The model should see the error "Tool failed visibly" and report it back.
|
||||||
|
expect(responseText).toContain('Tool failed visibly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles sendErrorsToModel: true correctly', async () => {
|
||||||
|
const goldenFile = getGoldenPath('tool-catchall-error');
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||||
|
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||||
|
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||||
|
tools: [
|
||||||
|
tool(
|
||||||
|
{
|
||||||
|
name: 'checkSystemStatus',
|
||||||
|
description: 'Checks the current system status',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
sendErrorsToModel: true,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
throw new Error('Standard error caught');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const stream = agent.sendStream(
|
||||||
|
'Check the system status and report any errors.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEvents = events.filter((e) => e.type === 'content');
|
||||||
|
const responseText = textEvents
|
||||||
|
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// The model should report the caught standard error.
|
||||||
|
expect(responseText.toLowerCase()).toContain('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { SdkTool, tool, ModelVisibleError } from './tool.js';
|
||||||
|
import type { MessageBus } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
// Mock MessageBus
|
||||||
|
const mockMessageBus = {} as unknown as MessageBus;
|
||||||
|
|
||||||
|
describe('tool()', () => {
|
||||||
|
it('creates a tool definition with defaults', () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'testTool',
|
||||||
|
description: 'A test tool',
|
||||||
|
inputSchema: z.object({ foo: z.string() }),
|
||||||
|
},
|
||||||
|
async () => 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.name).toBe('testTool');
|
||||||
|
expect(definition.description).toBe('A test tool');
|
||||||
|
expect(definition.sendErrorsToModel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a tool definition with explicit configuration', () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'testTool',
|
||||||
|
description: 'A test tool',
|
||||||
|
inputSchema: z.object({ foo: z.string() }),
|
||||||
|
sendErrorsToModel: true,
|
||||||
|
},
|
||||||
|
async () => 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.sendErrorsToModel).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SdkTool Execution', () => {
|
||||||
|
it('executes successfully', async () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'successTool',
|
||||||
|
description: 'Always succeeds',
|
||||||
|
inputSchema: z.object({ val: z.string() }),
|
||||||
|
},
|
||||||
|
async ({ val }) => `Success: ${val}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sdkTool = new SdkTool(definition, mockMessageBus);
|
||||||
|
const invocation = sdkTool.createInvocationWithContext(
|
||||||
|
{ val: 'test' },
|
||||||
|
mockMessageBus,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.llmContent).toBe('Success: test');
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws standard Error by default', async () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'failTool',
|
||||||
|
description: 'Always fails',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
throw new Error('Standard error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sdkTool = new SdkTool(definition, mockMessageBus);
|
||||||
|
const invocation = sdkTool.createInvocationWithContext(
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
invocation.execute(new AbortController().signal),
|
||||||
|
).rejects.toThrow('Standard error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches ModelVisibleError and returns ToolResult error', async () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'visibleErrorTool',
|
||||||
|
description: 'Fails with visible error',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
throw new ModelVisibleError('Visible error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sdkTool = new SdkTool(definition, mockMessageBus);
|
||||||
|
const invocation = sdkTool.createInvocationWithContext(
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(result.error?.message).toBe('Visible error');
|
||||||
|
expect(result.llmContent).toContain('Error: Visible error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches standard Error when sendErrorsToModel is true', async () => {
|
||||||
|
const definition = tool(
|
||||||
|
{
|
||||||
|
name: 'catchAllTool',
|
||||||
|
description: 'Catches all errors',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
sendErrorsToModel: true,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
throw new Error('Standard error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sdkTool = new SdkTool(definition, mockMessageBus);
|
||||||
|
const invocation = sdkTool.createInvocationWithContext(
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(result.error?.message).toBe('Standard error');
|
||||||
|
expect(result.llmContent).toContain('Error: Standard error');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
import {
|
||||||
|
BaseDeclarativeTool,
|
||||||
|
BaseToolInvocation,
|
||||||
|
type ToolResult,
|
||||||
|
type ToolInvocation,
|
||||||
|
Kind,
|
||||||
|
type MessageBus,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import type { SessionContext } from './types.js';
|
||||||
|
|
||||||
|
export { z };
|
||||||
|
|
||||||
|
export class ModelVisibleError extends Error {
|
||||||
|
constructor(message: string | Error) {
|
||||||
|
super(message instanceof Error ? message.message : message);
|
||||||
|
this.name = 'ModelVisibleError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDefinition<T extends z.ZodTypeAny> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: T;
|
||||||
|
sendErrorsToModel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tool<T extends z.ZodTypeAny> extends ToolDefinition<T> {
|
||||||
|
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SdkToolInvocation<T extends z.ZodTypeAny> extends BaseToolInvocation<
|
||||||
|
z.infer<T>,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
params: z.infer<T>,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
private readonly action: (
|
||||||
|
params: z.infer<T>,
|
||||||
|
context?: SessionContext,
|
||||||
|
) => Promise<unknown>,
|
||||||
|
private readonly context: SessionContext | undefined,
|
||||||
|
toolName: string,
|
||||||
|
private readonly sendErrorsToModel: boolean = false,
|
||||||
|
) {
|
||||||
|
super(params, messageBus, toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Executing ${this._toolName}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
_signal: AbortSignal,
|
||||||
|
_updateOutput?: (output: string) => void,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const result = await this.action(this.params, this.context);
|
||||||
|
const output =
|
||||||
|
typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||||
|
return {
|
||||||
|
llmContent: output,
|
||||||
|
returnDisplay: output,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (this.sendErrorsToModel || error instanceof ModelVisibleError) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
llmContent: `Error: ${errorMessage}`,
|
||||||
|
returnDisplay: `Error: ${errorMessage}`,
|
||||||
|
error: {
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SdkTool<T extends z.ZodTypeAny> extends BaseDeclarativeTool<
|
||||||
|
z.infer<T>,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private readonly definition: Tool<T>,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
_agent?: unknown,
|
||||||
|
private readonly context?: SessionContext,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
definition.name,
|
||||||
|
definition.name,
|
||||||
|
definition.description,
|
||||||
|
Kind.Other,
|
||||||
|
zodToJsonSchema(definition.inputSchema),
|
||||||
|
messageBus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindContext(context: SessionContext): SdkTool<T> {
|
||||||
|
return new SdkTool(this.definition, this.messageBus, undefined, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvocationWithContext(
|
||||||
|
params: z.infer<T>,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
context: SessionContext | undefined,
|
||||||
|
toolName?: string,
|
||||||
|
): ToolInvocation<z.infer<T>, ToolResult> {
|
||||||
|
return new SdkToolInvocation(
|
||||||
|
params,
|
||||||
|
messageBus,
|
||||||
|
this.definition.action,
|
||||||
|
context || this.context,
|
||||||
|
toolName || this.name,
|
||||||
|
this.definition.sendErrorsToModel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: z.infer<T>,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
toolName?: string,
|
||||||
|
): ToolInvocation<z.infer<T>, ToolResult> {
|
||||||
|
return new SdkToolInvocation(
|
||||||
|
params,
|
||||||
|
messageBus,
|
||||||
|
this.definition.action,
|
||||||
|
this.context,
|
||||||
|
toolName || this.name,
|
||||||
|
this.definition.sendErrorsToModel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tool<T extends z.ZodTypeAny>(
|
||||||
|
definition: ToolDefinition<T>,
|
||||||
|
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>,
|
||||||
|
): Tool<T> {
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Content } from '@google/gemini-cli-core';
|
||||||
|
import type { GeminiCliAgent } from './agent.js';
|
||||||
|
|
||||||
|
export interface AgentFilesystem {
|
||||||
|
readFile(path: string): Promise<string | null>;
|
||||||
|
writeFile(path: string, content: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentShellOptions {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentShellResult {
|
||||||
|
exitCode: number | null;
|
||||||
|
output: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentShell {
|
||||||
|
exec(cmd: string, options?: AgentShellOptions): Promise<AgentShellResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionContext {
|
||||||
|
sessionId: string;
|
||||||
|
transcript: Content[];
|
||||||
|
cwd: string;
|
||||||
|
timestamp: string;
|
||||||
|
fs: AgentFilesystem;
|
||||||
|
shell: AgentShell;
|
||||||
|
agent: GeminiCliAgent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"composite": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*.ts", "package.json"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"references": [{ "path": "../core" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user