mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f87c355bf3 | |||
| 19b9e3e8e4 | |||
| 59153cc1c5 | |||
| cc99774e1a | |||
| a40af4e03e | |||
| 1984c9d35b | |||
| 79cc1eb4f4 | |||
| 31584285a1 | |||
| 3b2708bbc8 | |||
| 25c1ed21e6 | |||
| 3ff97102a2 | |||
| 1c5646b68d | |||
| 5aba28c8be | |||
| cce060646c | |||
| ef80628b86 |
@@ -6,6 +6,7 @@
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
"devtools": true,
|
||||
"activeTeam": "test-team"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+19
-32
@@ -15752,6 +15752,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
|
||||
@@ -17473,22 +17489,6 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"packages/a2a-server/node_modules/tar": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
|
||||
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/a2a-server/node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
@@ -17658,22 +17658,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/tar": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
|
||||
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
@@ -17715,6 +17699,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -17739,6 +17724,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tar": "^7.5.13",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -17752,6 +17738,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/tar": "^6.1.13",
|
||||
"chrome-devtools-mcp": "^0.19.0",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -625,6 +625,16 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
|
||||
let teamRegistryURI =
|
||||
process.env['GEMINI_CLI_TEAM_REGISTRY_URI'] ??
|
||||
(trustedFolder ? settings.experimental?.teamRegistryURI : undefined);
|
||||
|
||||
if (teamRegistryURI && !teamRegistryURI.startsWith('http')) {
|
||||
teamRegistryURI = resolveToRealPath(
|
||||
path.resolve(cwd, resolvePath(teamRegistryURI)),
|
||||
);
|
||||
}
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
@@ -964,6 +974,7 @@ export async function loadCliConfig(
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionRegistryURI,
|
||||
teamRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
|
||||
@@ -5,29 +5,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
cloneFromGit,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
checkForExtensionUpdate,
|
||||
downloadFromGitHubRelease,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
} from './github.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as https from 'node:https';
|
||||
import * as tar from 'tar';
|
||||
import * as extract from 'extract-zip';
|
||||
import type { ExtensionManager } from '../extension-manager.js';
|
||||
import { fetchJson } from './github_fetch.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type {
|
||||
GeminiCLIExtension,
|
||||
ExtensionInstallMetadata,
|
||||
import {
|
||||
fetchReleaseFromGithub,
|
||||
type GeminiCLIExtension,
|
||||
type GithubReleaseData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
@@ -45,161 +30,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
fetchJson: vi.fn(),
|
||||
fetchReleaseFromGithub: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('simple-git');
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:https');
|
||||
vi.mock('tar');
|
||||
vi.mock('extract-zip');
|
||||
vi.mock('./github_fetch.js');
|
||||
vi.mock('../extension-manager.js');
|
||||
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
|
||||
vi.mock('../settings.js', () => ({
|
||||
loadSettings: vi.fn(),
|
||||
USER_SETTINGS_PATH: '/mock/settings.json',
|
||||
}));
|
||||
|
||||
describe('github.ts', () => {
|
||||
describe('github.ts (CLI specific)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneFromGit', () => {
|
||||
let mockGit: {
|
||||
clone: ReturnType<typeof vi.fn>;
|
||||
getRemotes: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
checkout: ReturnType<typeof vi.fn>;
|
||||
listRemote: ReturnType<typeof vi.fn>;
|
||||
revparse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
listRemote: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
});
|
||||
|
||||
it('should clone, fetch and checkout a repo', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await cloneFromGit(
|
||||
{
|
||||
type: 'git',
|
||||
source: 'https://github.com/owner/repo.git',
|
||||
ref: 'v1.0.0',
|
||||
},
|
||||
'/dest',
|
||||
);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/owner/repo.git',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
|
||||
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
|
||||
});
|
||||
|
||||
it('should throw if no remotes found', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
|
||||
).rejects.toThrow('Unable to find any remotes');
|
||||
});
|
||||
|
||||
it('should throw on clone error', async () => {
|
||||
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
|
||||
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
|
||||
).rejects.toThrow('Failed to clone Git repository');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseGithubUrl', () => {
|
||||
it.each([
|
||||
['https://github.com/owner/repo', 'owner', 'repo'],
|
||||
['https://github.com/owner/repo.git', 'owner', 'repo'],
|
||||
['git@github.com:owner/repo.git', 'owner', 'repo'],
|
||||
['owner/repo', 'owner', 'repo'],
|
||||
])('should parse %s to %s/%s', (url, owner, repo) => {
|
||||
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://gitlab.com/owner/repo',
|
||||
'https://my-git-host.com/owner/group/repo',
|
||||
'git@gitlab.com:some-group/some-project/some-repo.git',
|
||||
])('should return null for non-GitHub URLs', (url) => {
|
||||
expect(tryParseGithubUrl(url)).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for invalid formats', () => {
|
||||
expect(() => tryParseGithubUrl('invalid')).toThrow(
|
||||
'Invalid GitHub repository source',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchReleaseFromGithub', () => {
|
||||
it('should fetch latest release if no ref provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
await fetchReleaseFromGithub('owner', 'repo');
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch specific ref if provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pre-releases if allowed', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
|
||||
});
|
||||
|
||||
it('should return null if no releases found', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdate', () => {
|
||||
let mockExtensionManager: ExtensionManager;
|
||||
let mockGit: {
|
||||
@@ -302,348 +149,23 @@ describe('github.ts', () => {
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFromGitHubRelease', () => {
|
||||
it('should fail if no release data found', async () => {
|
||||
// Mock fetchJson to throw for latest release check
|
||||
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.failureReason).toBe('failed to fetch release data');
|
||||
}
|
||||
});
|
||||
|
||||
it('should use correct headers for release assets', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ name: 'asset.tar.gz', url: 'http://asset.url' }],
|
||||
});
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
|
||||
// Mock https.get and fs.createWriteStream for downloadFile
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
// Mock fs.promises.readdir to return empty array (no cleanup needed)
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
// Mock fs.promises.unlink
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
|
||||
|
||||
const promise = downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1.0.0',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
// Wait for downloadFile to be called and stream to be created
|
||||
await vi.waitUntil(
|
||||
() => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
// Trigger stream events to complete download
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'http://asset.url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/octet-stream',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use correct headers for source tarballs', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({
|
||||
tag_name: 'v1.0.0',
|
||||
it('should return UPDATE_AVAILABLE if github release tag differs', async () => {
|
||||
vi.mocked(fetchReleaseFromGithub).mockResolvedValue({
|
||||
tag_name: 'v2.0.0',
|
||||
assets: [],
|
||||
tarball_url: 'http://tarball.url',
|
||||
});
|
||||
} as unknown as GithubReleaseData);
|
||||
|
||||
// Mock https.get and fs.createWriteStream for downloadFile
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
const ext = {
|
||||
installMetadata: {
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
releaseTag: 'v1.0.0'
|
||||
},
|
||||
} as unknown as GeminiCLIExtension;
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
// Mock fs.promises.readdir to return empty array
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
// Mock fs.promises.unlink
|
||||
vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
|
||||
|
||||
const promise = downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1.0.0',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
// Wait for downloadFile to be called and stream to be created
|
||||
await vi.waitUntil(
|
||||
() => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
// Trigger stream events to complete download
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await promise;
|
||||
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'http://tarball.url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/vnd.github+json',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
it('should find platform/arch specific asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(os.arch).mockReturnValue('arm64');
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
|
||||
{ name: 'linux.x64.tar.gz', url: 'url2' },
|
||||
];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
|
||||
it('should find generic asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should download file successfully', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
const promise = downloadFile('url', '/dest');
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail on non-200 status', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 404 });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
|
||||
it('should follow redirects', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: { location: 'new-url' },
|
||||
});
|
||||
|
||||
const mockResSuccess =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResSuccess, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get)
|
||||
.mockImplementationOnce((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
})
|
||||
.mockImplementationOnce((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResSuccess);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
const promise = downloadFile('url', '/dest');
|
||||
mockResSuccess.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(https.get).toHaveBeenCalledTimes(2);
|
||||
expect(https.get).toHaveBeenLastCalledWith(
|
||||
'new-url',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail after too many redirects', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: { location: 'new-url' },
|
||||
});
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Too many redirects',
|
||||
);
|
||||
}, 10000); // Increase timeout for this test if needed, though with mocks it should be fast
|
||||
|
||||
it('should fail if redirect location is missing', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockResRedirect =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockResRedirect, {
|
||||
statusCode: 302,
|
||||
headers: {}, // No location
|
||||
});
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockResRedirect);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
await expect(downloadFile('url', '/dest')).rejects.toThrow(
|
||||
'Redirect response missing Location header',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') cb = options;
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
const promise = downloadFile('url', '/dest', {
|
||||
headers: { 'X-Custom': 'value' },
|
||||
});
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(https.get).toHaveBeenCalledWith(
|
||||
'url',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-Custom': 'value' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFile', () => {
|
||||
it('should extract tar.gz using tar', async () => {
|
||||
await extractFile('file.tar.gz', '/dest');
|
||||
expect(tar.x).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract zip using extract-zip', async () => {
|
||||
vi.mocked(extract.default || extract).mockResolvedValue(undefined);
|
||||
await extractFile('file.zip', '/dest');
|
||||
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
|
||||
});
|
||||
|
||||
it('should throw for unsupported extensions', async () => {
|
||||
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
|
||||
'Unsupported file extension',
|
||||
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,165 +8,39 @@ import { simpleGit } from 'simple-git';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import type { ExtensionManager } from '../extension-manager.js';
|
||||
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
* @param installMetadata The metadata for the extension to install.
|
||||
* @param destination The destination path to clone the repository to.
|
||||
*/
|
||||
export async function cloneFromGit(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl = installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
const parsedUrl = new URL(sourceUrl);
|
||||
if (
|
||||
parsedUrl.protocol === 'https:' &&
|
||||
parsedUrl.hostname === 'github.com'
|
||||
) {
|
||||
if (!parsedUrl.username) {
|
||||
parsedUrl.username = token;
|
||||
}
|
||||
sourceUrl = parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
// If source is not a valid URL, we don't inject the token.
|
||||
// We let git handle the source as is.
|
||||
}
|
||||
}
|
||||
await git.clone(sourceUrl, './', ['--depth', '1']);
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
fetchJson,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any remotes for repo ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refToFetch = installMetadata.ref || 'HEAD';
|
||||
|
||||
await git.fetch(remotes[0].name, refToFetch);
|
||||
|
||||
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
||||
// This results in a detached HEAD state, which is fine for this purpose.
|
||||
await git.checkout('FETCH_HEAD');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
export {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
fetchJson,
|
||||
};
|
||||
|
||||
export interface GithubRepoInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
|
||||
// Handle SCP-style SSH URLs.
|
||||
if (source.startsWith('git@')) {
|
||||
if (source.startsWith('git@github.com:')) {
|
||||
// It's a GitHub SSH URL, so normalize it for the URL parser.
|
||||
source = source.replace('git@github.com:', '');
|
||||
} else {
|
||||
// It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Default to a github repo path, so `source` can be just an org/repo
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
// Use the standard URL constructor for backward compatibility.
|
||||
parsedUrl = new URL(source, 'https://github.com');
|
||||
} catch (e) {
|
||||
// Throw a TypeError to maintain a consistent error contract for invalid URLs.
|
||||
// This avoids a breaking change for consumers who might expect a TypeError.
|
||||
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
|
||||
}
|
||||
|
||||
if (!parsedUrl) {
|
||||
throw new Error(`Invalid repo URL: ${source}`);
|
||||
}
|
||||
if (parsedUrl?.host !== 'github.com') {
|
||||
return null;
|
||||
}
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl?.pathname
|
||||
.split('/')
|
||||
// Remove the empty segments, fixes trailing and leading slashes
|
||||
.filter((part) => part !== '');
|
||||
|
||||
if (parts?.length !== 2) {
|
||||
throw new Error(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
);
|
||||
}
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace('.git', '');
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchReleaseFromGithub(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
allowPreRelease?: boolean,
|
||||
): Promise<GithubReleaseData | null> {
|
||||
if (ref) {
|
||||
return fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowPreRelease) {
|
||||
// Grab the release that is tagged as the "latest", github does not allow
|
||||
// this to be a pre-release so we can blindly grab it.
|
||||
try {
|
||||
return await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
||||
);
|
||||
} catch (_) {
|
||||
// This can fail if there is no release marked latest. In that case
|
||||
// we want to just try the pre-release logic below.
|
||||
}
|
||||
}
|
||||
|
||||
// If pre-releases are allowed, we just grab the most recent release.
|
||||
const releases = await fetchJson<GithubReleaseData[]>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`,
|
||||
);
|
||||
if (releases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return releases[0];
|
||||
}
|
||||
|
||||
export async function checkForExtensionUpdate(
|
||||
extension: GeminiCLIExtension,
|
||||
extensionManager: ExtensionManager,
|
||||
@@ -296,280 +170,3 @@ export async function checkForExtensionUpdate(
|
||||
return ExtensionUpdateState.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export type GitHubDownloadResult =
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: false;
|
||||
failureReason:
|
||||
| 'failed to fetch release data'
|
||||
| 'no release data'
|
||||
| 'no release asset found'
|
||||
| 'failed to download asset'
|
||||
| 'failed to extract asset'
|
||||
| 'unknown';
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: true;
|
||||
};
|
||||
export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
githubRepoInfo: GithubRepoInfo,
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { ref, allowPreRelease: preRelease } = installMetadata;
|
||||
const { owner, repo } = githubRepoInfo;
|
||||
let releaseData: GithubReleaseData | null = null;
|
||||
|
||||
try {
|
||||
try {
|
||||
releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
|
||||
if (!releaseData) {
|
||||
return {
|
||||
failureReason: 'no release data',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to fetch release data',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const asset = findReleaseAsset(releaseData.assets);
|
||||
let archiveUrl: string | undefined;
|
||||
let isTar = false;
|
||||
let isZip = false;
|
||||
let fileName: string | undefined;
|
||||
|
||||
if (asset) {
|
||||
archiveUrl = asset.url;
|
||||
fileName = asset.name;
|
||||
} else {
|
||||
if (releaseData.tarball_url) {
|
||||
archiveUrl = releaseData.tarball_url;
|
||||
isTar = true;
|
||||
} else if (releaseData.zipball_url) {
|
||||
archiveUrl = releaseData.zipball_url;
|
||||
isZip = true;
|
||||
}
|
||||
}
|
||||
if (!archiveUrl) {
|
||||
return {
|
||||
failureReason: 'no release asset found',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
|
||||
};
|
||||
}
|
||||
if (!fileName) {
|
||||
fileName = path.basename(new URL(archiveUrl).pathname);
|
||||
}
|
||||
let downloadedAssetPath = path.join(destination, fileName);
|
||||
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
|
||||
downloadedAssetPath += '.tar.gz';
|
||||
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
|
||||
downloadedAssetPath += '.zip';
|
||||
}
|
||||
|
||||
try {
|
||||
// GitHub API requires different Accept headers for different types of downloads:
|
||||
// 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content.
|
||||
// 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return
|
||||
// a 302 Redirect to the actual download location (codeload.github.com).
|
||||
// Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error.
|
||||
const headers = {
|
||||
...(asset
|
||||
? { Accept: 'application/octet-stream' }
|
||||
: { Accept: 'application/vnd.github+json' }),
|
||||
};
|
||||
await downloadFile(archiveUrl, downloadedAssetPath, { headers });
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to download asset',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await extractFile(downloadedAssetPath, destination);
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to extract asset',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// For regular github releases, the repository is put inside of a top level
|
||||
// directory. In this case we should see exactly two file in the destination
|
||||
// dir, the archive and the directory. If we see that, validate that the
|
||||
// dir has a gemini extension configuration file and then move all files
|
||||
// from the directory up one level into the destination directory.
|
||||
const entries = await fs.promises.readdir(destination, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (entries.length === 2) {
|
||||
const lonelyDir = entries.find((entry) => entry.isDirectory());
|
||||
if (
|
||||
lonelyDir &&
|
||||
fs.existsSync(
|
||||
path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME),
|
||||
)
|
||||
) {
|
||||
const dirPathToExtract = path.join(destination, lonelyDir.name);
|
||||
const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
|
||||
for (const file of extractedDirFiles) {
|
||||
await fs.promises.rename(
|
||||
path.join(dirPathToExtract, file),
|
||||
path.join(destination, file),
|
||||
);
|
||||
}
|
||||
await fs.promises.rmdir(dirPathToExtract);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(downloadedAssetPath);
|
||||
return {
|
||||
tagName: releaseData.tag_name,
|
||||
type: 'github-release',
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'unknown',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData?.tag_name,
|
||||
errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubReleaseData {
|
||||
assets: Asset[];
|
||||
tag_name: string;
|
||||
tarball_url?: string;
|
||||
zipball_url?: string;
|
||||
}
|
||||
|
||||
interface Asset {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
const platformArchPrefix = `${platform}.${arch}.`;
|
||||
const platformPrefix = `${platform}.`;
|
||||
|
||||
// Check for platform + architecture specific asset
|
||||
const platformArchAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformArchPrefix),
|
||||
);
|
||||
if (platformArchAsset) {
|
||||
return platformArchAsset;
|
||||
}
|
||||
|
||||
// Check for platform specific asset
|
||||
const platformAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformPrefix),
|
||||
);
|
||||
if (platformAsset) {
|
||||
return platformAsset;
|
||||
}
|
||||
|
||||
// Check for generic asset if only one is available
|
||||
const genericAsset = assets.find(
|
||||
(asset) =>
|
||||
!asset.name.toLowerCase().includes('darwin') &&
|
||||
!asset.name.toLowerCase().includes('linux') &&
|
||||
!asset.name.toLowerCase().includes('win32'),
|
||||
);
|
||||
if (assets.length === 1) {
|
||||
return genericAsset;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
options?: DownloadOptions,
|
||||
redirectCount: number = 0,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-agent': 'gemini-cli',
|
||||
Accept: 'application/octet-stream',
|
||||
...options?.headers,
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `token ${token}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
if (redirectCount >= 10) {
|
||||
return reject(new Error('Too many redirects'));
|
||||
}
|
||||
|
||||
if (!res.headers.location) {
|
||||
return reject(
|
||||
new Error('Redirect response missing Location header'),
|
||||
);
|
||||
}
|
||||
downloadFile(res.headers.location, dest, options, redirectCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve as () => void));
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractFile(file: string, dest: string): Promise<void> {
|
||||
if (file.endsWith('.tar.gz')) {
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: dest,
|
||||
});
|
||||
} else if (file.endsWith('.zip')) {
|
||||
await extract(file, { dir: dest });
|
||||
} else {
|
||||
throw new Error(`Unsupported file extension for extraction: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import * as https from 'node:https';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
|
||||
vi.mock('node:https');
|
||||
|
||||
describe('getGitHubToken', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
} else {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the token if GITHUB_TOKEN is set', () => {
|
||||
process.env['GITHUB_TOKEN'] = 'test-token';
|
||||
expect(getGitHubToken()).toBe('test-token');
|
||||
});
|
||||
|
||||
it('should return undefined if GITHUB_TOKEN is not set', () => {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
expect(getGitHubToken()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchJson', () => {
|
||||
const getMock = vi.mocked(https.get);
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and parse JSON successfully', async () => {
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo":'));
|
||||
res.emit('data', Buffer.from('"bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle redirects (301 and 302)', async () => {
|
||||
// Test 302
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 302;
|
||||
res.headers = { location: 'https://example.com/final' };
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
getMock.mockImplementationOnce((url, _options, callback) => {
|
||||
expect(url).toBe('https://example.com/final');
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"success": true}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
// Test 301
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 301;
|
||||
res.headers = { location: 'https://example.com/final-permanent' };
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
getMock.mockImplementationOnce((url, _options, callback) => {
|
||||
expect(url).toBe('https://example.com/final-permanent');
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"permanent": true}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchJson('https://example.com/redirect-perm'),
|
||||
).resolves.toEqual({ permanent: true });
|
||||
});
|
||||
|
||||
it('should reject on non-200/30x status code', async () => {
|
||||
getMock.mockImplementationOnce((_url, _options, callback) => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 404;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject on request error', async () => {
|
||||
const error = new Error('Network error');
|
||||
getMock.mockImplementationOnce(() => {
|
||||
const req = new EventEmitter() as ClientRequest;
|
||||
req.emit('error', error);
|
||||
return req;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
});
|
||||
|
||||
describe('with GITHUB_TOKEN', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['GITHUB_TOKEN'] = 'my-secret-token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
} else {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should include Authorization header if token is present', async () => {
|
||||
getMock.mockImplementationOnce((_url, options, callback) => {
|
||||
expect(options.headers).toEqual({
|
||||
'User-Agent': 'gemini-cli',
|
||||
Authorization: 'token my-secret-token',
|
||||
});
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo": "bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without GITHUB_TOKEN', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('should not include Authorization header if token is not present', async () => {
|
||||
getMock.mockImplementationOnce((_url, options, callback) => {
|
||||
expect(options.headers).toEqual({
|
||||
'User-Agent': 'gemini-cli',
|
||||
});
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 200;
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('data', Buffer.from('{"foo": "bar"}'));
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,51 +4,4 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
|
||||
export function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
redirectCount: number = 0,
|
||||
): Promise<T> {
|
||||
const headers: { 'User-Agent': string; Authorization?: string } = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
if (redirectCount >= 10) {
|
||||
return reject(new Error('Too many redirects'));
|
||||
}
|
||||
if (!res.headers.location) {
|
||||
return reject(new Error('No location header in redirect response'));
|
||||
}
|
||||
fetchJson<T>(res.headers.location, redirectCount++)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
export { getGitHubToken, fetchJson } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -2009,6 +2009,16 @@ const SETTINGS_SCHEMA = {
|
||||
'The URI (web URL or local file path) of the extension registry.',
|
||||
showInDialog: false,
|
||||
},
|
||||
teamRegistryURI: {
|
||||
type: 'string',
|
||||
label: 'Team Registry URI',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 'https://geminicli.com/teams.json',
|
||||
description:
|
||||
'The URI (web URL or local file path) of the agent team registry.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
|
||||
@@ -57,6 +57,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { teamCommand } from '../ui/commands/teamCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -222,6 +223,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: []),
|
||||
settingsCommand,
|
||||
tasksCommand,
|
||||
teamCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -526,6 +526,11 @@ const baseMockUiState = {
|
||||
pendingHistoryItems: [],
|
||||
mainControlsRef: () => {},
|
||||
rootUiRef: { current: null },
|
||||
adminSettingsChanged: false,
|
||||
newAgents: null,
|
||||
isTeamSelectionActive: false,
|
||||
isTeamCreatorActive: false,
|
||||
showIsExpandableHint: false,
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -533,7 +538,7 @@ export const mockAppState: AppState = {
|
||||
startupWarnings: [],
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
export const mockUIActions: UIActions = {
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
@@ -589,6 +594,9 @@ const mockUIActions: UIActions = {
|
||||
onHintSubmit: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
handleTeamSelect: vi.fn(),
|
||||
handleRefreshTeams: vi.fn(),
|
||||
setIsTeamCreatorActive: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -415,6 +415,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
const [isTeamSelectionActive, setIsTeamSelectionActive] = useState(false);
|
||||
const [isTeamCreatorActive, setIsTeamCreatorActive] = useState(false);
|
||||
|
||||
const logger = useLogger(config.storage);
|
||||
const { inputHistory, addInput, initializeFromLogger } =
|
||||
@@ -444,6 +446,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
|
||||
// Check if team selection is needed
|
||||
const teamRegistry = config.getTeamRegistry();
|
||||
const availableTeams = teamRegistry.getAllTeams();
|
||||
const activeTeam = config.getActiveTeam();
|
||||
|
||||
if (availableTeams.length > 0 && !activeTeam) {
|
||||
setIsTeamSelectionActive(true);
|
||||
}
|
||||
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -947,6 +959,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
},
|
||||
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
|
||||
setText: stableSetText,
|
||||
setIsTeamSelectionActive,
|
||||
setIsTeamCreatorActive,
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -966,6 +980,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
setShortcutsHelpVisible,
|
||||
stableSetText,
|
||||
setIsTeamSelectionActive,
|
||||
setIsTeamCreatorActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1407,6 +1423,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
|
||||
const handleTeamSelect = useCallback(
|
||||
async (teamName: string | undefined) => {
|
||||
await config.setActiveTeam(teamName);
|
||||
setIsTeamSelectionActive(false);
|
||||
refreshStatic();
|
||||
},
|
||||
[config, refreshStatic],
|
||||
);
|
||||
|
||||
const handleRefreshTeams = useCallback(async () => {
|
||||
await config.getTeamRegistry().reload();
|
||||
setForceRerenderKey((prev) => prev + 1);
|
||||
}, [config]);
|
||||
/**
|
||||
* Determines if the input prompt should be active and accept user input.
|
||||
* Input is disabled during:
|
||||
@@ -2046,6 +2075,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
isTeamSelectionActive ||
|
||||
isTeamCreatorActive ||
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
@@ -2377,6 +2408,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isBackgroundTaskListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
isTeamSelectionActive,
|
||||
isTeamCreatorActive,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
|
||||
@@ -2504,6 +2537,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
isTeamSelectionActive,
|
||||
isTeamCreatorActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2600,6 +2635,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
setNewAgents(null);
|
||||
},
|
||||
handleTeamSelect,
|
||||
handleRefreshTeams,
|
||||
setIsTeamCreatorActive,
|
||||
getPreferredEditor,
|
||||
clearAccountSuspension: () => {
|
||||
setAccountSuspensionInfo(null);
|
||||
@@ -2661,6 +2699,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
handleTeamSelect,
|
||||
handleRefreshTeams,
|
||||
setIsTeamCreatorActive,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Slash command to manage agent teams.
|
||||
*/
|
||||
export const teamCommand: SlashCommand = {
|
||||
name: 'team',
|
||||
description: 'Manage agent teams',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'select',
|
||||
description: 'Open the team selection dialog',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'teamSelection',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'create',
|
||||
description: 'Create a new agent team interactively',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'teamCreator',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -126,6 +126,8 @@ export interface OpenDialogActionReturn {
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'agentConfig'
|
||||
| 'teamSelection'
|
||||
| 'teamCreator'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { TeamSelectionDialog } from './TeamSelectionDialog.js';
|
||||
import { TeamCreatorWizard } from './TeamCreatorWizard.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
@@ -60,6 +62,23 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
if (uiState.isTeamSelectionActive) {
|
||||
return (
|
||||
<TeamSelectionDialog
|
||||
teams={config.getTeamRegistry().getAllTeams()}
|
||||
onSelect={uiActions.handleTeamSelect}
|
||||
onRefreshTeams={uiActions.handleRefreshTeams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isTeamCreatorActive) {
|
||||
return (
|
||||
<TeamCreatorWizard
|
||||
onComplete={() => uiActions.setIsTeamCreatorActive(false)}
|
||||
onCancel={() => uiActions.setIsTeamCreatorActive(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { type ThoughtSummary } from '../types.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { ApprovalMode, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../hooks/useComposerStatus.js', () => ({
|
||||
useComposerStatus: vi.fn(),
|
||||
@@ -142,4 +142,52 @@ describe('<StatusRow />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders ActiveTeamIndicator with provider letters', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const mockTeam = {
|
||||
name: 'poly-team',
|
||||
displayName: 'Polyglot',
|
||||
agents: [
|
||||
{ kind: 'local', name: 'g' },
|
||||
{ kind: 'external', name: 'c', provider: 'claude-code' },
|
||||
{ kind: 'external', name: 'x', provider: 'codex' },
|
||||
],
|
||||
};
|
||||
|
||||
const config = makeFakeConfig();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
config.getActiveTeam = () => mockTeam as any;
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={true}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState: defaultUiState,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('[ Team: Polyglot');
|
||||
expect(output).toContain('Claude Cod');
|
||||
expect(output).toContain('Codex');
|
||||
expect(output).toContain('Gemini CLI');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import {
|
||||
isUserVisibleHook,
|
||||
@@ -16,6 +15,7 @@ import { type ActiveHook } from '../types.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
@@ -25,6 +25,7 @@ import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ProviderTag } from './shared/ProviderTag.js';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
|
||||
/**
|
||||
@@ -146,6 +147,45 @@ export const StatusNode: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an indicator for the currently active agent team.
|
||||
*/
|
||||
const ActiveTeamIndicator: React.FC = () => {
|
||||
const config = useConfig();
|
||||
const activeTeam =
|
||||
typeof config?.getActiveTeam === 'function'
|
||||
? config.getActiveTeam()
|
||||
: undefined;
|
||||
|
||||
if (!activeTeam) return null;
|
||||
|
||||
const providers = new Set<string>();
|
||||
for (const agent of activeTeam.agents) {
|
||||
if (agent.kind === 'external') {
|
||||
providers.add(agent.provider);
|
||||
} else {
|
||||
providers.add('gemini');
|
||||
}
|
||||
}
|
||||
|
||||
const sortedProviders = Array.from(providers).sort();
|
||||
|
||||
return (
|
||||
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN} flexDirection="row">
|
||||
<Text color={theme.text.accent}>[ Team: {activeTeam.displayName} (</Text>
|
||||
{sortedProviders.map((p, i) => (
|
||||
<React.Fragment key={p}>
|
||||
<ProviderTag provider={p} />
|
||||
{i < sortedProviders.length - 1 && (
|
||||
<Text color={theme.text.accent}>, </Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Text color={theme.text.accent}>) ]</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
showUiDetails,
|
||||
isNarrow,
|
||||
@@ -393,6 +433,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
<ActiveTeamIndicator />
|
||||
</>
|
||||
) : (
|
||||
showRow2Minimal &&
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Box, Text, useStdin } from 'ink';
|
||||
import {
|
||||
type ScaffoldTeamAgent,
|
||||
scaffoldTeam,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { type SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { ProviderTag, PROVIDER_COLORS } from './shared/ProviderTag.js';
|
||||
|
||||
interface TeamCreatorWizardProps {
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type WizardStep =
|
||||
| 'identity'
|
||||
| 'roster'
|
||||
| 'objective'
|
||||
| 'confirmation'
|
||||
| 'success';
|
||||
|
||||
const EXTERNAL_TEMPLATES: RosterListItem[] = [
|
||||
{
|
||||
name: 'claude-coder',
|
||||
kind: 'external',
|
||||
provider: 'claude-code',
|
||||
description: 'Expert Claude coder focused on direct action.',
|
||||
logoColor: PROVIDER_COLORS['claude-code'],
|
||||
},
|
||||
{
|
||||
name: 'codex-architect',
|
||||
kind: 'external',
|
||||
provider: 'codex',
|
||||
description: 'Specialized code generation and architectural patterns.',
|
||||
logoColor: PROVIDER_COLORS['codex'],
|
||||
},
|
||||
{
|
||||
name: 'antigravity-creative',
|
||||
kind: 'external',
|
||||
provider: 'antigravity',
|
||||
description: 'Creative problem solving and unconventional solutions.',
|
||||
logoColor: PROVIDER_COLORS['antigravity'],
|
||||
},
|
||||
{
|
||||
name: 'gemma-helper',
|
||||
kind: 'external',
|
||||
provider: 'gemma',
|
||||
description: 'Lightweight and efficient general purpose assistant.',
|
||||
logoColor: PROVIDER_COLORS['gemma'],
|
||||
},
|
||||
];
|
||||
|
||||
interface RosterListItem {
|
||||
name: string;
|
||||
kind: 'local' | 'external';
|
||||
provider?: string;
|
||||
description?: string;
|
||||
sourcePath?: string;
|
||||
logo?: string;
|
||||
logoColor?: string;
|
||||
}
|
||||
|
||||
export function TeamCreatorWizard({
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: TeamCreatorWizardProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [step, setStep] = useState<WizardStep>('identity');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [instructions, setInstructions] = useState('');
|
||||
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdPath, setCreatedPath] = useState<string | null>(null);
|
||||
const [rosterMode] = useState<'list' | 'action'>('list');
|
||||
|
||||
const localAgents = useMemo(
|
||||
() =>
|
||||
config
|
||||
?.getAgentRegistry()
|
||||
.getAllDiscoveredDefinitions()
|
||||
.filter((a) => a.kind === 'local') || [],
|
||||
[config],
|
||||
);
|
||||
|
||||
const allAvailableAgents = useMemo((): RosterListItem[] => {
|
||||
const local: RosterListItem[] = localAgents.map((a) => {
|
||||
let logo = '✦'; // Default Gemini Icon
|
||||
let logoColor = PROVIDER_COLORS['gemini'];
|
||||
|
||||
if (a.name === 'codebase-investigator') {
|
||||
logo = '🔍';
|
||||
logoColor = '#87D7D7'; // Cyan
|
||||
} else if (a.name === 'cli-help') {
|
||||
logo = '';
|
||||
logoColor = '#FBBC04'; // Yellow
|
||||
} else if (a.name === 'generalist') {
|
||||
logoColor = '#4285F4'; // Google Blue
|
||||
}
|
||||
|
||||
return {
|
||||
name: a.name,
|
||||
kind: 'local' as const,
|
||||
description: a.description,
|
||||
sourcePath: (a as any).sourcePath,
|
||||
logo,
|
||||
logoColor,
|
||||
};
|
||||
});
|
||||
|
||||
const external: RosterListItem[] = EXTERNAL_TEMPLATES;
|
||||
|
||||
return [...local, ...external];
|
||||
}, [localAgents]);
|
||||
|
||||
const teamProviders = useMemo(() => {
|
||||
const providers = new Set<string>();
|
||||
for (const name of selectedAgents) {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name);
|
||||
if (agent?.provider) {
|
||||
providers.add(agent.provider);
|
||||
} else {
|
||||
providers.add('gemini');
|
||||
}
|
||||
}
|
||||
return Array.from(providers).sort();
|
||||
}, [selectedAgents, allAvailableAgents]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (step === 'identity') {
|
||||
if (!teamName || !displayName) {
|
||||
setError('Team ID and Display Name are required.');
|
||||
return;
|
||||
}
|
||||
setStep('roster');
|
||||
} else if (step === 'roster') {
|
||||
setStep('objective');
|
||||
} else if (step === 'objective') {
|
||||
if (!instructions) {
|
||||
setError('Team mission instructions are required.');
|
||||
return;
|
||||
}
|
||||
setStep('confirmation');
|
||||
} else if (step === 'confirmation') {
|
||||
void handleSubmit();
|
||||
}
|
||||
}, [step, teamName, displayName, instructions]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const agents: ScaffoldTeamAgent[] = Array.from(selectedAgents).map(
|
||||
(name) => {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name)!;
|
||||
return {
|
||||
name,
|
||||
kind: agent.kind,
|
||||
provider: agent.provider,
|
||||
description: agent.description,
|
||||
sourcePath: agent.sourcePath,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const path = await scaffoldTeam({
|
||||
name: teamName,
|
||||
displayName,
|
||||
description,
|
||||
instructions,
|
||||
agents,
|
||||
targetDir: config.storage.getProjectTeamsDir(),
|
||||
});
|
||||
|
||||
setCreatedPath(path);
|
||||
setStep('success');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to create team.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (step === 'roster') setStep('identity');
|
||||
else if (step === 'objective') setStep('roster');
|
||||
else if (step === 'confirmation') setStep('objective');
|
||||
}, [step]);
|
||||
|
||||
const nameBuffer = useTextBuffer({
|
||||
initialText: teamName,
|
||||
viewport: { width: 100, height: 100 },
|
||||
onChange: setTeamName,
|
||||
});
|
||||
const displayNameBuffer = useTextBuffer({
|
||||
initialText: displayName,
|
||||
viewport: { width: 100, height: 100 },
|
||||
onChange: setDisplayName,
|
||||
});
|
||||
const descriptionBuffer = useTextBuffer({
|
||||
initialText: description,
|
||||
viewport: { width: 100, height: 100 },
|
||||
onChange: setDescription,
|
||||
});
|
||||
const instructionsBuffer = useTextBuffer({
|
||||
initialText: instructions,
|
||||
viewport: { width: 100, height: 100 },
|
||||
onChange: setInstructions,
|
||||
});
|
||||
|
||||
const { stdin } = useStdin();
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'b' && !stdin.isPaused()) {
|
||||
handleBack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: step !== 'identity' && step !== 'success' },
|
||||
);
|
||||
|
||||
const handleToggleAgent = useCallback((name: string) => {
|
||||
setSelectedAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (step === 'identity') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 1: Team Identity
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>Unique Team ID (slug):</Text>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput buffer={nameBuffer} placeholder="e.g. frontend-team" />
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Display Name:</Text>
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput
|
||||
buffer={displayNameBuffer}
|
||||
placeholder="e.g. Frontend Experts"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Description (Optional):</Text>
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor={theme.ui.comment} paddingX={1}>
|
||||
<TextInput
|
||||
buffer={descriptionBuffer}
|
||||
placeholder="Briefly describe the team's purpose"
|
||||
onSubmit={handleNext}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to continue"
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'roster') {
|
||||
const items: SelectionListItem<RosterListItem>[] = allAvailableAgents.map(
|
||||
(a) => ({
|
||||
key: a.name,
|
||||
label: a.name,
|
||||
value: a,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 2: Team Roster
|
||||
</Text>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select the agents that will participate in this team.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box height={12} width="100%">
|
||||
<BaseSelectionList<RosterListItem>
|
||||
items={items}
|
||||
onSelect={(value) => handleToggleAgent(value.name)}
|
||||
renderItem={(item, context) => {
|
||||
const value = item.value;
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="row" width="100%">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{selectedAgents.has(value.name) ? '☑' : '☐'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
context.isSelected && rosterMode === 'list'
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{value.logo && (
|
||||
<Text color={value.logoColor || theme.text.accent}>
|
||||
{value.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
{value.name}
|
||||
</Text>
|
||||
<Box marginLeft={1}>
|
||||
<ProviderTag provider={value.provider || 'gemini'} />
|
||||
</Box>
|
||||
</Box>
|
||||
{value.description && (
|
||||
<Text color={theme.text.secondary} italic wrap="truncate">
|
||||
{' '}
|
||||
{value.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
isFocused={rosterMode === 'list'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
>
|
||||
<Text
|
||||
bold
|
||||
color={
|
||||
rosterMode === 'action' ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{rosterMode === 'action' ? '> ' : ' '}
|
||||
[ Done - Continue to Objective ]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<DialogFooter
|
||||
primaryAction={
|
||||
rosterMode === 'list' ? 'Enter to toggle agent' : 'Enter to continue'
|
||||
}
|
||||
navigationActions={
|
||||
rosterMode === 'list' ? 'Tab to continue' : 'Tab to select agents'
|
||||
}
|
||||
extraParts={['[B]ack to Step 1']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'objective') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 3: Team Objective
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Define what this team does and how it should work.
|
||||
</Text>
|
||||
{selectedAgents.size > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.text.secondary}>Providers: </Text>
|
||||
{teamProviders.map((p, i) => (
|
||||
<Box key={p} marginLeft={i === 0 ? 0 : 1}>
|
||||
<ProviderTag provider={p} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.ui.comment}>Referencing agents: </Text>
|
||||
{Array.from(selectedAgents).map((name, i) => {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name);
|
||||
return (
|
||||
<Box key={name} flexDirection="row">
|
||||
{agent?.logo && (
|
||||
<Text color={agent.logoColor || theme.text.accent}>
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.ui.comment}>
|
||||
@{name}{' '}
|
||||
</Text>
|
||||
<ProviderTag provider={agent?.provider || 'gemini'} />
|
||||
{i < selectedAgents.size - 1 && (
|
||||
<Text color={theme.ui.comment}>, </Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.ui.comment}
|
||||
paddingX={1}
|
||||
>
|
||||
<TextInput buffer={instructionsBuffer} onSubmit={handleNext} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to continue"
|
||||
navigationActions={`Tab to go back · ${formatCommand(Command.OPEN_EXTERNAL_EDITOR)} for full editor`}
|
||||
cancelAction="Esc to cancel"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirmation') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Step 4: Confirm Team Configuration
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Name:{' '}
|
||||
<Text color={theme.text.primary}>
|
||||
{displayName} ({teamName})
|
||||
</Text>
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Description: <Text color={theme.text.primary}>{description}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Roster:</Text>
|
||||
</Box>
|
||||
{Array.from(selectedAgents).map((name) => {
|
||||
const agent = allAvailableAgents.find((a) => a.name === name);
|
||||
return (
|
||||
<Box key={name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>- </Text>
|
||||
{agent?.logo && (
|
||||
<Text color={agent.logoColor || theme.text.accent}>
|
||||
{agent.logo}{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{name}{' '}
|
||||
</Text>
|
||||
<ProviderTag provider={agent?.provider || 'gemini'} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedAgents.size === 0 && (
|
||||
<Text color={theme.status.warning} italic>
|
||||
No agents selected (Gemini CLI expert will be used by default)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isSubmitting && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.accent}>Creating team...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to create team"
|
||||
cancelAction="Esc to cancel"
|
||||
extraParts={['[B]ack to Objective']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.success}
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.status.success}>
|
||||
Success! Team Created
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
Team {displayName} has been saved to:
|
||||
</Text>
|
||||
<Text color={theme.text.accent}>{createdPath}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
You can now select this team from the main team selection menu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>[Enter] Finish</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box />;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders, mockUIActions } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import { TeamSelectionDialog } from './TeamSelectionDialog.js';
|
||||
|
||||
describe('<TeamSelectionDialog />', () => {
|
||||
const mockTeams = [
|
||||
{
|
||||
name: 'team-one',
|
||||
displayName: 'Team One',
|
||||
description: 'First team description',
|
||||
agents: [],
|
||||
instructions: '',
|
||||
configPath: '/test/team1',
|
||||
},
|
||||
{
|
||||
name: 'team-two',
|
||||
displayName: 'Team Two',
|
||||
description: 'Second team description',
|
||||
agents: [],
|
||||
instructions: '',
|
||||
configPath: '/test/team2',
|
||||
},
|
||||
];
|
||||
|
||||
it('renders a list of discovered teams and standard options', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Select an Agent Team');
|
||||
expect(lastFrame()).toContain('Team One');
|
||||
expect(lastFrame()).toContain('Team Two');
|
||||
expect(lastFrame()).toContain('The Polyglot Team');
|
||||
expect(lastFrame()).toContain('No Team');
|
||||
expect(lastFrame()).toContain('Browse Team Marketplace');
|
||||
expect(lastFrame()).toContain('Create Team');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onSelect with team name when a team is selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter on first item
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('team-one');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onSelect with undefined when No Team is selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// Navigate to No Team (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows marketplace view when selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// Navigate to Marketplace (index 4)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Agent Team Marketplace');
|
||||
expect(lastFrame()).toContain(
|
||||
'community marketplace is currently under development',
|
||||
);
|
||||
|
||||
// Go back with escape
|
||||
await act(async () => {
|
||||
stdin.write('\u001B'); // Escape
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Select an Agent Team');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('launches team creator wizard when Create Team is selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<TeamSelectionDialog teams={mockTeams} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
// Navigate to Create Team (index 5)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(mockUIActions.setIsTeamCreatorActive).toHaveBeenCalledWith(true);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type TeamDefinition,
|
||||
type AgentDefinition,
|
||||
installRegistryTeam,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { TeamRegistryView } from './views/TeamRegistryView.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ProviderTag } from './shared/ProviderTag.js';
|
||||
|
||||
interface TeamSelectionDialogProps {
|
||||
teams: TeamDefinition[];
|
||||
onSelect: (teamName: string | undefined) => void;
|
||||
onRefreshTeams?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const MultiModelBadge: React.FC = () => (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
Multi-Model
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
function getProviderTags(agents: AgentDefinition[]): React.ReactNode {
|
||||
const providers = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (agent.kind === 'external') {
|
||||
providers.add(agent.provider);
|
||||
} else {
|
||||
providers.add('gemini');
|
||||
}
|
||||
}
|
||||
|
||||
const sortedProviders = Array.from(providers).sort();
|
||||
const isMulti = providers.size > 1;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Box flexDirection="row">
|
||||
{sortedProviders.map((p, i) => (
|
||||
<Box key={p} marginLeft={i === 0 ? 0 : 1}>
|
||||
<ProviderTag provider={p} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{isMulti && <MultiModelBadge />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamSelectionDialog({
|
||||
teams: discoveredTeams,
|
||||
onSelect,
|
||||
onRefreshTeams,
|
||||
}: TeamSelectionDialogProps): React.JSX.Element {
|
||||
const uiActions = useUIActions();
|
||||
const config = useConfig();
|
||||
const [view, setView] = useState<'select' | 'marketplace'>('select');
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
setView('select');
|
||||
return true;
|
||||
},
|
||||
{ isActive: view !== 'select' },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const list = discoveredTeams.map((team) => ({
|
||||
value: team.name,
|
||||
title: team.displayName || team.name,
|
||||
description: team.description,
|
||||
key: team.name,
|
||||
titleSuffix: getProviderTags(team.agents),
|
||||
}));
|
||||
|
||||
// Curated "Marketplace" Teams (Hardcoded MVP)
|
||||
const polyglotTeam: TeamDefinition = {
|
||||
name: 'curated-polyglot',
|
||||
displayName: 'The Polyglot Team',
|
||||
description:
|
||||
'Advanced multi-model orchestration with Gemini, Claude Code, and Codex.',
|
||||
instructions: 'Orchestrate across multiple specialized models.',
|
||||
agents: [
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
kind: 'local',
|
||||
name: 'gemini-expert',
|
||||
description: 'Gemini Expert',
|
||||
inputConfig: { type: 'object', properties: {}, required: [] },
|
||||
} as unknown as AgentDefinition,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
kind: 'external',
|
||||
name: 'claude-coder',
|
||||
provider: 'claude-code',
|
||||
description: 'Claude Coder',
|
||||
inputConfig: { type: 'object', properties: {}, required: [] },
|
||||
} as unknown as AgentDefinition,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
kind: 'external',
|
||||
name: 'codex-architect',
|
||||
provider: 'codex',
|
||||
description: 'Codex Architect',
|
||||
inputConfig: { type: 'object', properties: {}, required: [] },
|
||||
} as unknown as AgentDefinition,
|
||||
],
|
||||
};
|
||||
|
||||
list.push({
|
||||
value: polyglotTeam.name,
|
||||
title: `${polyglotTeam.displayName} (Curated)`,
|
||||
description: polyglotTeam.description,
|
||||
key: polyglotTeam.name,
|
||||
titleSuffix: getProviderTags(polyglotTeam.agents),
|
||||
});
|
||||
|
||||
list.push({
|
||||
value: 'none',
|
||||
title: 'No Team',
|
||||
description: 'Continue with standard Gemini CLI experience',
|
||||
key: 'none',
|
||||
titleSuffix: undefined,
|
||||
});
|
||||
|
||||
list.push({
|
||||
value: 'marketplace',
|
||||
title: 'Browse Agent Teams',
|
||||
description: 'Discover and install teams from the community',
|
||||
key: 'marketplace',
|
||||
titleSuffix: undefined,
|
||||
});
|
||||
|
||||
list.push({
|
||||
value: 'create',
|
||||
title: 'Create Team',
|
||||
description: 'Define your own agent team and orchestration instructions',
|
||||
key: 'create',
|
||||
titleSuffix: undefined,
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [discoveredTeams]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === 'none') {
|
||||
onSelect(undefined);
|
||||
} else if (value === 'marketplace') {
|
||||
setView('marketplace');
|
||||
} else if (value === 'create') {
|
||||
uiActions.setIsTeamCreatorActive(true);
|
||||
onSelect(undefined);
|
||||
} else {
|
||||
onSelect(value);
|
||||
}
|
||||
},
|
||||
[onSelect, uiActions],
|
||||
);
|
||||
|
||||
const handleInstallTeam = useCallback(
|
||||
async (registryTeam: any) => {
|
||||
try {
|
||||
const targetDir = config.storage.getProjectTeamsDir();
|
||||
await installRegistryTeam(registryTeam, targetDir);
|
||||
await onRefreshTeams?.();
|
||||
// After installation, we go back to selection screen
|
||||
setView('select');
|
||||
} catch (error) {
|
||||
// Errors are currently handled by the UI feedback system via coreEvents if they bubble up
|
||||
}
|
||||
},
|
||||
[config, onRefreshTeams],
|
||||
);
|
||||
|
||||
if (view === 'marketplace') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<TeamRegistryView
|
||||
installedTeams={discoveredTeams}
|
||||
onInstall={handleInstallTeam}
|
||||
onClose={() => setView('select')}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Select an Agent Team
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose a specialized team to orchestrate your tasks.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
showNumbers={true}
|
||||
maxItemsToShow={10}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use arrow keys to navigate, Enter to select)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleSuffix?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DescriptiveRadioButtonSelectProps<T> {
|
||||
@@ -61,7 +62,10 @@ export function DescriptiveRadioButtonSelect<T>({
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" key={item.key}>
|
||||
<Text color={titleColor}>{item.title}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={titleColor}>{item.title}</Text>
|
||||
{item.titleSuffix && <Box marginLeft={1}>{item.titleSuffix}</Box>}
|
||||
</Box>
|
||||
{item.description && (
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export const PROVIDER_COLORS: Record<string, string> = {
|
||||
'claude-code': '#C15F3C', // Claude Orange
|
||||
codex: '#FFFFFF', // Codex White
|
||||
gemini: '#A855F7', // Gemini Purple
|
||||
antigravity: '#93C5FD', // Antigravity Light Blue
|
||||
gemma: '#60A5FA', // Gemma Blue
|
||||
};
|
||||
|
||||
export function getProviderLabel(provider: string): string {
|
||||
switch (provider) {
|
||||
case 'claude-code':
|
||||
return 'Claude Code';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'antigravity':
|
||||
return 'Antigravity';
|
||||
case 'gemma':
|
||||
return 'Gemma';
|
||||
default:
|
||||
return 'Gemini CLI';
|
||||
}
|
||||
}
|
||||
|
||||
export const ProviderTag: React.FC<{ provider: string }> = ({ provider }) => {
|
||||
const label = getProviderLabel(provider);
|
||||
const color = PROVIDER_COLORS[provider] || theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Text color={color} bold>
|
||||
[{label}]
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type RegistryTeam,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { ProviderTag } from '../shared/ProviderTag.js';
|
||||
|
||||
export interface TeamDetailsViewProps {
|
||||
team: RegistryTeam;
|
||||
onBack: () => void;
|
||||
onInstall: () => void | Promise<void>;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
export function TeamDetailsView({
|
||||
team,
|
||||
onBack,
|
||||
onInstall,
|
||||
isInstalled,
|
||||
}: TeamDetailsViewProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onBack();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
|
||||
setIsInstalling(true);
|
||||
void (async () => {
|
||||
await onInstall();
|
||||
setIsInstalling(false);
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (isInstalling) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
Installing Team {team.displayName}...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
height="100%"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{'>'} Agent Teams {'>'}{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{team.displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{team.version ? `v${team.version}` : ''} |{' '}
|
||||
</Text>
|
||||
<Text color={theme.status.warning}>⭐ </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{String(team.stars || 0)} |{' '}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>@{team.name}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>{team.description}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Author */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>Author: </Text>
|
||||
<Text color={theme.text.primary}>{team.author || 'Community'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Agents Roster */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
Team Roster:
|
||||
</Text>
|
||||
{team.agents.map((agent) => (
|
||||
<Box key={agent.name} marginLeft={2} flexDirection="row">
|
||||
<Box width={15} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>@{agent.name}</Text>
|
||||
</Box>
|
||||
<Box width={18} flexShrink={0} marginX={1}>
|
||||
<ProviderTag provider={agent.provider} />
|
||||
</Box>
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{agent.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Instructions Summary */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
Team Mission:
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{team.instructions}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Spacer to push installation UI to bottom */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Installation UI */}
|
||||
{!isInstalled ? (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
This team will be installed to your local .gemini/teams/ directory.
|
||||
Agent teams may contain custom instructions and configurations.
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Text color={theme.text.primary}>[{'Enter'}] Install Team</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>[Esc] Back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="row" marginTop={1} justifyContent="center" padding={1}>
|
||||
<Text color={theme.status.success}>Team Already Installed</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>[Esc] Back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type RegistryTeam,
|
||||
type TeamDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SearchableList,
|
||||
type GenericListItem,
|
||||
} from '../shared/SearchableList.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
import { useTeamRegistry } from '../../hooks/useTeamRegistry.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { TeamDetailsView } from './TeamDetailsView.js';
|
||||
import { ProviderTag } from '../shared/ProviderTag.js';
|
||||
|
||||
export interface TeamRegistryViewProps {
|
||||
onInstall?: (team: RegistryTeam) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
installedTeams: TeamDefinition[];
|
||||
}
|
||||
|
||||
interface TeamListItem extends GenericListItem {
|
||||
team: RegistryTeam;
|
||||
}
|
||||
|
||||
export function TeamRegistryView({
|
||||
onInstall,
|
||||
onClose,
|
||||
installedTeams,
|
||||
}: TeamRegistryViewProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const { teams, loading, error, search } = useTeamRegistry(
|
||||
'',
|
||||
config.getTeamRegistryURI(),
|
||||
);
|
||||
const { terminalHeight, staticExtraHeight } = useUIState();
|
||||
const [selectedTeam, setSelectedTeam] = useState<RegistryTeam | null>(null);
|
||||
|
||||
const items: TeamListItem[] = useMemo(
|
||||
() =>
|
||||
teams.map((team) => ({
|
||||
key: team.id,
|
||||
label: team.displayName,
|
||||
description: team.description,
|
||||
team,
|
||||
})),
|
||||
[teams],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((item: TeamListItem) => {
|
||||
setSelectedTeam(item.team);
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleInstallAction = useCallback(
|
||||
async (team: RegistryTeam) => {
|
||||
await onInstall?.(team);
|
||||
// Go back to list view after install
|
||||
setSelectedTeam(null);
|
||||
},
|
||||
[onInstall],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: TeamListItem, isActive: boolean, _labelWidth: number) => {
|
||||
const isInstalled = installedTeams.some((t) => t.name === item.team.name);
|
||||
const providers = Array.from(
|
||||
new Set(item.team.agents.map((a) => a.provider || 'gemini')),
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" marginBottom={1}>
|
||||
<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>
|
||||
)}
|
||||
<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.team.stars || 0}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="row" alignItems="center">
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{item.team.agents.length} agent
|
||||
{item.team.agents.length === 1 ? '' : 's'}{' '}
|
||||
</Text>
|
||||
<Box marginLeft={1} flexDirection="row">
|
||||
{providers.map((p) => (
|
||||
<Box key={p} marginRight={1}>
|
||||
<ProviderTag provider={p} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[installedTeams],
|
||||
);
|
||||
|
||||
const header = useMemo(
|
||||
() => (
|
||||
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
Browse and search Agent Teams from the registry.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{installedTeams.length > 0 &&
|
||||
`${installedTeams.length} teams installed`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
[installedTeams.length],
|
||||
);
|
||||
|
||||
const footer = useCallback(
|
||||
({
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalVisible,
|
||||
}: {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
totalVisible: number;
|
||||
}) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
({startIndex + 1}-{endIndex}) / {totalVisible}
|
||||
</Text>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const maxItemsToShow = useMemo(() => {
|
||||
const staticHeight = 10;
|
||||
const availableTerminalHeight = terminalHeight - staticExtraHeight;
|
||||
const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight);
|
||||
const itemHeight = 3; // Increased due to multi-line rendering
|
||||
return Math.max(4, Math.floor(remainingHeight / itemHeight));
|
||||
}, [terminalHeight, staticExtraHeight]);
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color={theme.text.secondary}>Loading Agent Teams...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text color={theme.status.error}>Error loading teams:</Text>
|
||||
<Text color={theme.text.secondary}>{error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display={selectedTeam ? 'none' : 'flex'}
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<SearchableList<TeamListItem>
|
||||
title="Browse Agent Teams"
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose || (() => {})}
|
||||
searchPlaceholder="Search agent teams"
|
||||
renderItem={renderItem}
|
||||
header={header}
|
||||
footer={footer}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
useSearch={useRegistrySearch}
|
||||
onSearch={search}
|
||||
resetSelectionOnItemsChange={true}
|
||||
isFocused={!selectedTeam}
|
||||
/>
|
||||
</Box>
|
||||
{selectedTeam && (
|
||||
<TeamDetailsView
|
||||
team={selectedTeam}
|
||||
onBack={handleBack}
|
||||
onInstall={async () => {
|
||||
await handleInstallAction(selectedTeam);
|
||||
}}
|
||||
isInstalled={installedTeams.some((t) => t.name === selectedTeam.name)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -854,10 +854,7 @@ export function KeypressProvider({
|
||||
useEffect(() => {
|
||||
terminalCapabilityManager.enableSupportedModes();
|
||||
|
||||
const wasRaw = stdin.isRaw;
|
||||
if (wasRaw === false) {
|
||||
setRawMode(true);
|
||||
}
|
||||
setRawMode(true);
|
||||
|
||||
process.stdin.setEncoding('utf8'); // Make data events emit strings
|
||||
|
||||
@@ -882,9 +879,7 @@ export function KeypressProvider({
|
||||
stdin.on('data', dataListener);
|
||||
return () => {
|
||||
stdin.removeListener('data', dataListener);
|
||||
if (wasRaw === false) {
|
||||
setRawMode(false);
|
||||
}
|
||||
setRawMode(false);
|
||||
};
|
||||
}, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]);
|
||||
|
||||
|
||||
@@ -91,7 +91,11 @@ export interface UIActions {
|
||||
onHintSubmit: (hint: string) => void;
|
||||
handleRestart: () => void;
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
handleTeamSelect: (teamName: string | undefined) => void;
|
||||
handleRefreshTeams: () => Promise<void>;
|
||||
setIsTeamCreatorActive: (active: boolean) => void;
|
||||
getPreferredEditor: () => EditorType;
|
||||
|
||||
clearAccountSuspension: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,8 @@ export interface UIState {
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
isTeamSelectionActive: boolean;
|
||||
isTeamCreatorActive: boolean;
|
||||
showIsExpandableHint: boolean;
|
||||
hintMode: boolean;
|
||||
hintBuffer: string;
|
||||
|
||||
@@ -216,6 +216,8 @@ describe('useSlashCommandProcessor', () => {
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
setIsTeamSelectionActive: vi.fn(),
|
||||
setIsTeamCreatorActive: vi.fn(),
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
true, // isConfigInitialized
|
||||
|
||||
@@ -87,6 +87,8 @@ interface SlashCommandProcessorActions {
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
setText: (text: string) => void;
|
||||
setIsTeamSelectionActive: (active: boolean) => void;
|
||||
setIsTeamCreatorActive: (active: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,6 +502,12 @@ export const useSlashCommandProcessor = (
|
||||
case 'model':
|
||||
actions.openModelDialog();
|
||||
return { type: 'handled' };
|
||||
case 'teamSelection':
|
||||
actions.setIsTeamSelectionActive(true);
|
||||
return { type: 'handled' };
|
||||
case 'teamCreator':
|
||||
actions.setIsTeamCreatorActive(true);
|
||||
return { type: 'handled' };
|
||||
case 'agentConfig': {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const props = result.props as Record<string, unknown>;
|
||||
|
||||
@@ -34,9 +34,11 @@ describe('useFocus', () => {
|
||||
pause: vi.fn(),
|
||||
});
|
||||
stdout = { write: vi.fn() };
|
||||
mockedUseStdin.mockReturnValue({ stdin } as unknown as ReturnType<
|
||||
typeof useStdin
|
||||
>);
|
||||
mockedUseStdin.mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: vi.fn(),
|
||||
isRawModeSupported: true,
|
||||
} as unknown as ReturnType<typeof useStdin>);
|
||||
mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType<
|
||||
typeof useStdout
|
||||
>);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
TeamRegistryClient,
|
||||
type RegistryTeam,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseTeamRegistryResult {
|
||||
teams: RegistryTeam[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: (query: string) => void;
|
||||
}
|
||||
|
||||
export function useTeamRegistry(
|
||||
initialQuery = '',
|
||||
registryURI?: string,
|
||||
): UseTeamRegistryResult {
|
||||
const [teams, setTeams] = useState<RegistryTeam[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const client = useMemo(
|
||||
() => new TeamRegistryClient(registryURI),
|
||||
[registryURI],
|
||||
);
|
||||
|
||||
// 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 searchTeams = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const results = await client.searchTeams(query);
|
||||
|
||||
// Only update if this is still the latest query
|
||||
if (query === latestQueryRef.current) {
|
||||
// Check if results are different from current teams
|
||||
setTeams((prev) => {
|
||||
if (
|
||||
prev.length === results.length &&
|
||||
prev.every((team, i) => team.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));
|
||||
setTeams([]);
|
||||
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 searchTeams(query);
|
||||
}, 300);
|
||||
},
|
||||
[searchTeams],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void searchTeams(initialQuery);
|
||||
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [initialQuery, searchTeams]);
|
||||
|
||||
return {
|
||||
teams,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import React, { useMemo } from 'react';
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
StyledLine,
|
||||
toStyledCharacters,
|
||||
wordBreakStyledChars,
|
||||
wrapStyledChars,
|
||||
@@ -16,6 +15,12 @@ import {
|
||||
styledCharsWidth,
|
||||
styledCharsToString,
|
||||
} from 'ink';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
type StyledLine = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
import { stripUnsafeCharacters } from './textUtils.js';
|
||||
@@ -100,12 +105,12 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = Array.from({ length: numColumns }).map(
|
||||
(_, colIndex) => {
|
||||
const headerStyledLine = styledHeaders[colIndex] || StyledLine.empty(0);
|
||||
const headerStyledLine = styledHeaders[colIndex] || [];
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(headerStyledLine);
|
||||
|
||||
styledRows.forEach((row) => {
|
||||
const cellStyledLine = row[colIndex] || StyledLine.empty(0);
|
||||
const cellStyledLine = row[colIndex] || [];
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cellStyledLine);
|
||||
|
||||
@@ -180,7 +185,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
// Ensure we iterate up to numColumns, filling with empty cells if needed
|
||||
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
|
||||
const cellStyledLine = row[colIndex] || StyledLine.empty(0);
|
||||
const cellStyledLine = row[colIndex] || [];
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
@@ -208,11 +213,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row));
|
||||
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map(
|
||||
(w) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
w + COLUMN_PADDING,
|
||||
);
|
||||
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
|
||||
|
||||
return { wrappedHeaders, wrappedRows, adjustedWidths };
|
||||
}, [styledHeaders, styledRows, terminalWidth]);
|
||||
@@ -263,7 +264,6 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell, width, isHeader);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -83,6 +84,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tar": "^7.5.13",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -106,6 +108,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/tar": "^6.1.13",
|
||||
"chrome-devtools-mcp": "^0.19.0",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -211,9 +211,35 @@ const remoteAgentSchema = z.union([
|
||||
|
||||
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
|
||||
|
||||
export const externalAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('external'),
|
||||
name: nameSchema,
|
||||
provider: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
display_name: z.string().optional(),
|
||||
provider_config: z.record(z.any()).optional(),
|
||||
instructions: z.string().optional(),
|
||||
tools: z
|
||||
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
|
||||
message: 'Invalid tool name',
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
mcp_servers: z.record(mcpServerSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type FrontmatterExternalAgentDefinition = z.infer<typeof externalAgentSchema>;
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
| FrontmatterLocalAgentDefinition
|
||||
| FrontmatterRemoteAgentDefinition;
|
||||
| FrontmatterRemoteAgentDefinition
|
||||
| FrontmatterExternalAgentDefinition;
|
||||
|
||||
const agentUnionOptions = [
|
||||
{ label: 'Local Agent' },
|
||||
@@ -222,11 +248,10 @@ const agentUnionOptions = [
|
||||
];
|
||||
|
||||
const remoteAgentsListSchema = z.array(remoteAgentSchema);
|
||||
|
||||
const markdownFrontmatterSchema = z.union([
|
||||
localAgentSchema,
|
||||
remoteAgentUrlSchema,
|
||||
remoteAgentJsonSchema,
|
||||
remoteAgentSchema,
|
||||
externalAgentSchema,
|
||||
]);
|
||||
|
||||
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
|
||||
@@ -368,6 +393,17 @@ export async function parseAgentMarkdown(
|
||||
];
|
||||
}
|
||||
|
||||
if (frontmatter.kind === 'external') {
|
||||
const instr = body.trim();
|
||||
return [
|
||||
{
|
||||
...frontmatter,
|
||||
kind: 'external',
|
||||
instructions: instr,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Construct the local agent definition
|
||||
return [
|
||||
{
|
||||
@@ -474,91 +510,147 @@ export function markdownToAgentDefinition(
|
||||
},
|
||||
};
|
||||
|
||||
if (markdown.kind === 'remote') {
|
||||
const base: RemoteAgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: markdown.name,
|
||||
description: markdown.description || '',
|
||||
displayName: markdown.display_name,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
switch (markdown.kind) {
|
||||
case 'remote': {
|
||||
const base: RemoteAgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: markdown.name,
|
||||
description: markdown.description || '',
|
||||
displayName: markdown.display_name,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (
|
||||
'agent_card_json' in markdown &&
|
||||
markdown.agent_card_json !== undefined
|
||||
) {
|
||||
base.agentCardJson = markdown.agent_card_json;
|
||||
return base;
|
||||
}
|
||||
if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) {
|
||||
base.agentCardUrl = markdown.agent_card_url;
|
||||
return base;
|
||||
if (
|
||||
'agent_card_json' in markdown &&
|
||||
markdown.agent_card_json !== undefined
|
||||
) {
|
||||
base.agentCardJson = markdown.agent_card_json;
|
||||
return base;
|
||||
}
|
||||
if (
|
||||
'agent_card_url' in markdown &&
|
||||
markdown.agent_card_url !== undefined
|
||||
) {
|
||||
base.agentCardUrl = markdown.agent_card_url;
|
||||
return base;
|
||||
}
|
||||
|
||||
throw new AgentLoadError(
|
||||
metadata?.filePath || 'unknown',
|
||||
'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent',
|
||||
);
|
||||
}
|
||||
|
||||
throw new AgentLoadError(
|
||||
metadata?.filePath || 'unknown',
|
||||
'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent',
|
||||
);
|
||||
}
|
||||
case 'external': {
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.http_url,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.type,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.include_tools,
|
||||
config.exclude_tools,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
return {
|
||||
kind: 'external',
|
||||
name: markdown.name,
|
||||
provider: markdown.provider,
|
||||
description: markdown.description,
|
||||
displayName: markdown.display_name,
|
||||
providerConfig: markdown.provider_config,
|
||||
instructions: markdown.instructions,
|
||||
toolConfig: markdown.tools
|
||||
? {
|
||||
tools: markdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.http_url,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.type,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.include_tools,
|
||||
config.exclude_tools,
|
||||
case 'local': {
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.http_url,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.type,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.include_tools,
|
||||
config.exclude_tools,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
name: markdown.name,
|
||||
description: markdown.description,
|
||||
displayName: markdown.display_name,
|
||||
promptConfig: {
|
||||
systemPrompt: markdown.system_prompt,
|
||||
query: '${query}',
|
||||
},
|
||||
modelConfig: {
|
||||
model: modelName,
|
||||
generateContentConfig: {
|
||||
temperature: markdown.temperature ?? 1,
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS,
|
||||
maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
|
||||
},
|
||||
toolConfig: markdown.tools
|
||||
? {
|
||||
tools: markdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustive: never = markdown;
|
||||
throw new Error(
|
||||
`Unsupported agent kind: ${String((exhaustive as { kind: string }).kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
name: markdown.name,
|
||||
description: markdown.description,
|
||||
displayName: markdown.display_name,
|
||||
promptConfig: {
|
||||
systemPrompt: markdown.system_prompt,
|
||||
query: '${query}',
|
||||
},
|
||||
modelConfig: {
|
||||
model: modelName,
|
||||
generateContentConfig: {
|
||||
temperature: markdown.temperature ?? 1,
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS,
|
||||
maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
|
||||
},
|
||||
toolConfig: markdown.tools
|
||||
? {
|
||||
tools: markdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { TeamRegistry } from './teamRegistry.js';
|
||||
import { AgentRegistry } from './registry.js';
|
||||
import { ExternalAgentInvocation } from './external-invocation.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
|
||||
describe('External Agent E2E-ish Verification', () => {
|
||||
let tempGeminiDir: string;
|
||||
let config: Config;
|
||||
let agentRegistry: AgentRegistry;
|
||||
let teamRegistry: TeamRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempGeminiDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
const projectGeminiDir = path.join(tempGeminiDir, '.gemini');
|
||||
const teamsDir = path.join(projectGeminiDir, 'teams', 'test-team');
|
||||
const agentsDir = path.join(teamsDir, 'agents');
|
||||
await fs.mkdir(agentsDir, { recursive: true });
|
||||
|
||||
// Create a real external agent file
|
||||
const claudeMd = `---
|
||||
kind: external
|
||||
name: claude-verifier
|
||||
provider: claude-code
|
||||
description: I am a Claude polyfill.
|
||||
---
|
||||
# Extra Instructions
|
||||
Be very concise.
|
||||
`;
|
||||
await fs.writeFile(path.join(agentsDir, 'claude.md'), claudeMd);
|
||||
|
||||
// Create a TEAM.md
|
||||
const teamMd = `---
|
||||
name: test-team
|
||||
display_name: Test Team
|
||||
description: A test team for external agents.
|
||||
---
|
||||
Orchestration instructions.
|
||||
`;
|
||||
await fs.writeFile(path.join(teamsDir, 'TEAM.md'), teamMd);
|
||||
|
||||
// Initialize Config using makeFakeConfig
|
||||
config = makeFakeConfig({
|
||||
targetDir: tempGeminiDir,
|
||||
cwd: tempGeminiDir,
|
||||
folderTrust: false,
|
||||
enableAgents: true,
|
||||
});
|
||||
|
||||
agentRegistry = new AgentRegistry(config);
|
||||
teamRegistry = new TeamRegistry(config, agentRegistry);
|
||||
await agentRegistry.initialize();
|
||||
await teamRegistry.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempGeminiDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load external agent and apply polyfill on invocation', async () => {
|
||||
const teams = teamRegistry.getAllTeams();
|
||||
const agents = agentRegistry.getAllDefinitions();
|
||||
const agentNames = agents.map((a) => a.name);
|
||||
|
||||
expect(teams.length).toBeGreaterThan(0);
|
||||
expect(agentNames).toContain('claude-verifier');
|
||||
|
||||
const claudeAgent = agents.find((a) => a.name === 'claude-verifier');
|
||||
|
||||
expect(claudeAgent).toBeDefined();
|
||||
expect(claudeAgent?.kind).toBe('external');
|
||||
if (claudeAgent?.kind !== 'external') return;
|
||||
|
||||
expect(claudeAgent.provider).toBe('claude-code');
|
||||
|
||||
// Verify Invocation applies polyfill
|
||||
const messageBus = createMockMessageBus();
|
||||
const invocation = new ExternalAgentInvocation(
|
||||
claudeAgent,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
config as any,
|
||||
{},
|
||||
messageBus,
|
||||
);
|
||||
|
||||
// Check protected polyfilled definition via casting
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const polyfilled = (invocation as any).definition;
|
||||
expect(polyfilled.kind).toBe('local');
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Claude Code Personality Overlay',
|
||||
);
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain('Be very concise.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { type ExternalAgentDefinition } from './types.js';
|
||||
import {
|
||||
ExternalAgentInvocation,
|
||||
polyfillExternalAgent,
|
||||
} from './external-invocation.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
|
||||
vi.mock('./local-executor.js');
|
||||
|
||||
const MockLocalAgentExecutor = vi.mocked(LocalAgentExecutor);
|
||||
|
||||
describe('ExternalAgentInvocation', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockConfig: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockMessageBus: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
mockMessageBus = createMockMessageBus();
|
||||
|
||||
MockLocalAgentExecutor.create.mockResolvedValue({
|
||||
run: vi.fn().mockResolvedValue({
|
||||
result: 'Success',
|
||||
terminate_reason: 'GOAL',
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
definition: {} as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const externalDef: ExternalAgentDefinition = {
|
||||
kind: 'external',
|
||||
name: 'claude-agent',
|
||||
provider: 'claude-code',
|
||||
description: 'Expert coder.',
|
||||
inputConfig: { inputSchema: { type: 'object', properties: {} } },
|
||||
};
|
||||
|
||||
it('should polyfill external agent into a local agent definition', () => {
|
||||
const polyfilled = polyfillExternalAgent(externalDef);
|
||||
|
||||
expect(polyfilled.kind).toBe('local');
|
||||
expect(polyfilled.modelConfig.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Claude Code Personality Overlay',
|
||||
);
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain('Expert coder.');
|
||||
});
|
||||
|
||||
it('should support Codex provider', () => {
|
||||
const codexDef: ExternalAgentDefinition = {
|
||||
...externalDef,
|
||||
provider: 'codex',
|
||||
};
|
||||
const polyfilled = polyfillExternalAgent(codexDef);
|
||||
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Codex Personality Overlay',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support Antigravity provider', () => {
|
||||
const antigravityDef: ExternalAgentDefinition = {
|
||||
...externalDef,
|
||||
provider: 'antigravity',
|
||||
};
|
||||
const polyfilled = polyfillExternalAgent(antigravityDef);
|
||||
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Antigravity Personality Overlay',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support Gemma provider', () => {
|
||||
const gemmaDef: ExternalAgentDefinition = {
|
||||
...externalDef,
|
||||
provider: 'gemma',
|
||||
};
|
||||
const polyfilled = polyfillExternalAgent(gemmaDef);
|
||||
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Gemma Personality Overlay',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include styleInstructions from providerConfig', () => {
|
||||
const customDef: ExternalAgentDefinition = {
|
||||
...externalDef,
|
||||
providerConfig: {
|
||||
styleInstructions: 'Be very formal.',
|
||||
},
|
||||
};
|
||||
const polyfilled = polyfillExternalAgent(customDef);
|
||||
|
||||
expect(polyfilled.promptConfig.systemPrompt).toContain(
|
||||
'Additional Style Instructions: Be very formal.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use ExternalAgentInvocation to execute polyfilled agent', async () => {
|
||||
const invocation = new ExternalAgentInvocation(
|
||||
externalDef,
|
||||
mockConfig,
|
||||
{},
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
await invocation.execute(signal);
|
||||
|
||||
expect(MockLocalAgentExecutor.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'local',
|
||||
name: 'claude-agent',
|
||||
}),
|
||||
mockConfig,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import {
|
||||
type ExternalAgentDefinition,
|
||||
type LocalAgentDefinition,
|
||||
type AgentInputs,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
|
||||
/**
|
||||
* An invocation instance for an "external" agent.
|
||||
*
|
||||
* This class applies a personality overlay to a model call to mimic
|
||||
* the behavior of an external agent provider using a standard Gemini model.
|
||||
*/
|
||||
export class ExternalAgentInvocation extends LocalSubagentInvocation {
|
||||
constructor(
|
||||
definition: ExternalAgentDefinition,
|
||||
context: AgentLoopContext,
|
||||
params: AgentInputs,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
const polyfilledDefinition = polyfillExternalAgent(definition);
|
||||
super(
|
||||
polyfilledDefinition,
|
||||
context,
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an ExternalAgentDefinition into a LocalAgentDefinition by applying
|
||||
* provider-specific personality overlays and model configurations.
|
||||
*/
|
||||
export function polyfillExternalAgent(
|
||||
def: ExternalAgentDefinition,
|
||||
): LocalAgentDefinition {
|
||||
const overlay = getPersonalityOverlay(def.provider, def.providerConfig);
|
||||
|
||||
// External agents use the instructions field (from markdown body) as their primary instructions.
|
||||
// We fall back to description if instructions are missing.
|
||||
const baseInstructions = def.instructions || def.description;
|
||||
|
||||
return {
|
||||
...def,
|
||||
kind: 'local',
|
||||
modelConfig: {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
runConfig: {
|
||||
maxTurns: 30,
|
||||
maxTimeMinutes: 10,
|
||||
},
|
||||
promptConfig: {
|
||||
// Prepend the personality overlay to the agent's instructions.
|
||||
systemPrompt: `${overlay}\n\n${baseInstructions}`,
|
||||
query: '${query}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a personality overlay prompt based on the external provider.
|
||||
*/
|
||||
function getPersonalityOverlay(
|
||||
provider: string,
|
||||
config?: Record<string, unknown>,
|
||||
): string {
|
||||
const styleInstructions = config?.['styleInstructions']
|
||||
? `\nAdditional Style Instructions: ${String(config['styleInstructions'])}`
|
||||
: '';
|
||||
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'claude-code':
|
||||
return `
|
||||
# Claude Code Personality Overlay
|
||||
You are acting as the "Claude Code" agent, a high-performance, concise, and direct coding assistant.
|
||||
- Focus on efficiency and direct action.
|
||||
- Use tools precisely and explain your technical reasoning briefly and only when necessary.
|
||||
- Maintain a senior software engineer persona: professional, expert, and focused on clean, maintainable code.
|
||||
- Avoid unnecessary conversational filler or lengthy preambles.
|
||||
${styleInstructions}`.trim();
|
||||
|
||||
case 'codex':
|
||||
return `
|
||||
# Codex Personality Overlay
|
||||
You are acting as the "Codex" agent, a specialized code generation model.
|
||||
- Focus on generating high-quality, idiomatic, and correct code for the requested task.
|
||||
- Prioritize structural integrity and idiomatic patterns for the target language.
|
||||
- Provide clear, well-documented code snippets.
|
||||
${styleInstructions}`.trim();
|
||||
|
||||
case 'antigravity':
|
||||
return `
|
||||
# Antigravity Personality Overlay
|
||||
You are acting as the "Antigravity" agent.
|
||||
- Focus on creative problem solving and thinking "outside the box".
|
||||
- Adopt a playful but highly competent engineering persona.
|
||||
- Encourage unconventional but effective solutions.
|
||||
${styleInstructions}`.trim();
|
||||
|
||||
case 'gemma':
|
||||
return `
|
||||
# Gemma Personality Overlay
|
||||
You are acting as the "Gemma" agent, an open, lightweight, and capable model.
|
||||
- Focus on accessibility and efficiency.
|
||||
- Provide helpful, clear, and easy-to-understand explanations.
|
||||
- Maintain a friendly and supportive persona.
|
||||
${styleInstructions}`.trim();
|
||||
|
||||
default:
|
||||
return `
|
||||
# External Agent Personality Overlay
|
||||
You are acting as an external specialized agent ('${provider}').
|
||||
Please adopt the persona, style, and expertise expected of this provider.
|
||||
${styleInstructions}`.trim();
|
||||
}
|
||||
}
|
||||
@@ -312,21 +312,96 @@ export class AgentRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters an agent definition from the active agents list.
|
||||
* It remains in the discovered definitions list.
|
||||
*/
|
||||
unregisterAgent(name: string): void {
|
||||
this.agents.delete(name);
|
||||
|
||||
// Unregister model configs
|
||||
this.config.modelConfigService.unregisterRuntimeModelConfig(
|
||||
`${name}-config`,
|
||||
);
|
||||
this.config.modelConfigService.unregisterRuntimeModelOverridesByScope(name);
|
||||
|
||||
// Remove policies
|
||||
const policyEngine = this.config.getPolicyEngine();
|
||||
if (policyEngine) {
|
||||
policyEngine.removeRulesForTool(name, 'AgentRegistry (Dynamic)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an agent definition for discovery only.
|
||||
*/
|
||||
registerDiscoveredAgent(definition: AgentDefinition): void {
|
||||
this.allDefinitions.set(definition.name, definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an agent definition. If an agent with the same name exists,
|
||||
* it will be overwritten, respecting the precedence established by the
|
||||
* initialization order.
|
||||
*/
|
||||
protected async registerAgent<TOutput extends z.ZodTypeAny>(
|
||||
async registerAgent<TOutput extends z.ZodTypeAny>(
|
||||
definition: AgentDefinition<TOutput>,
|
||||
): Promise<void> {
|
||||
if (definition.kind === 'local') {
|
||||
this.registerLocalAgent(definition);
|
||||
} else if (definition.kind === 'remote') {
|
||||
await this.registerRemoteAgent(definition);
|
||||
} else if (definition.kind === 'external') {
|
||||
this.registerExternalAgent(definition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an external agent definition synchronously.
|
||||
*/
|
||||
protected registerExternalAgent<TOutput extends z.ZodTypeAny>(
|
||||
definition: AgentDefinition<TOutput>,
|
||||
): void {
|
||||
if (definition.kind !== 'external') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (!definition.name || !definition.description || !definition.provider) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Skipping invalid external agent definition. Missing name, description, or provider.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.allDefinitions.set(definition.name, definition);
|
||||
|
||||
const settingsOverrides =
|
||||
this.config.getAgentsSettings().overrides?.[definition.name];
|
||||
|
||||
if (!this.isAgentEnabled(definition, settingsOverrides)) {
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[AgentRegistry] Skipping disabled external agent '${definition.name}'`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.agents.set(definition.name, definition);
|
||||
|
||||
// Register default model config for external agents
|
||||
this.registerModelConfigs({
|
||||
...definition,
|
||||
kind: 'local',
|
||||
modelConfig: { model: 'inherit' },
|
||||
runConfig: {},
|
||||
promptConfig: {},
|
||||
});
|
||||
|
||||
this.addAgentPolicy(definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a local agent definition synchronously.
|
||||
*/
|
||||
@@ -394,7 +469,7 @@ export class AgentRegistry {
|
||||
policyEngine.addRule({
|
||||
toolName: definition.name,
|
||||
decision:
|
||||
definition.kind === 'local'
|
||||
definition.kind === 'local' || definition.metadata?.isTeamAgent
|
||||
? PolicyDecision.ALLOW
|
||||
: PolicyDecision.ASK_USER,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
@@ -692,6 +767,13 @@ export class AgentRegistry {
|
||||
return Array.from(this.agents.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all discovered agent definitions.
|
||||
*/
|
||||
getAllDiscoveredDefinitions(): AgentDefinition[] {
|
||||
return Array.from(this.allDefinitions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all registered agent names.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { RemoteAgentInvocation } from './remote-invocation.js';
|
||||
import { ExternalAgentInvocation } from './external-invocation.js';
|
||||
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
|
||||
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -72,35 +73,54 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
const definition = this.definition;
|
||||
const effectiveMessageBus = messageBus;
|
||||
|
||||
if (definition.kind === 'remote') {
|
||||
return new RemoteAgentInvocation(
|
||||
definition,
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
switch (definition.kind) {
|
||||
case 'remote':
|
||||
return new RemoteAgentInvocation(
|
||||
definition,
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
|
||||
// Special handling for browser agent - needs async MCP setup
|
||||
if (definition.name === BROWSER_AGENT_NAME) {
|
||||
return new BrowserAgentInvocation(
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
case 'external':
|
||||
return new ExternalAgentInvocation(
|
||||
definition,
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
|
||||
return new LocalSubagentInvocation(
|
||||
definition,
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
case 'local':
|
||||
// Special handling for browser agent - needs async MCP setup
|
||||
if (definition.name === BROWSER_AGENT_NAME) {
|
||||
return new BrowserAgentInvocation(
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
|
||||
return new LocalSubagentInvocation(
|
||||
definition,
|
||||
this.context,
|
||||
params,
|
||||
effectiveMessageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
|
||||
default: {
|
||||
const exhaustive: never = definition;
|
||||
throw new Error(
|
||||
`Unsupported agent kind: ${String((exhaustive as { kind: string }).kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
|
||||
private readonly definition: AgentDefinition,
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
descriptionOverride?: string,
|
||||
) {
|
||||
const inputSchema = definition.inputConfig.inputSchema;
|
||||
|
||||
@@ -47,7 +48,7 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
|
||||
super(
|
||||
definition.name,
|
||||
definition.displayName ?? definition.name,
|
||||
definition.description,
|
||||
descriptionOverride ?? definition.description,
|
||||
Kind.Agent,
|
||||
inputSchema,
|
||||
messageBus,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { loadTeamsFromDirectory } from './teamLoader.js';
|
||||
|
||||
describe('teamLoader', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createTeamStructure(
|
||||
teamName: string,
|
||||
teamMdContent: string,
|
||||
agents: Record<string, string> = {},
|
||||
) {
|
||||
const teamPath = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamPath, { recursive: true });
|
||||
await fs.writeFile(path.join(teamPath, 'TEAM.md'), teamMdContent);
|
||||
|
||||
if (Object.keys(agents).length > 0) {
|
||||
const agentsPath = path.join(teamPath, 'agents');
|
||||
await fs.mkdir(agentsPath, { recursive: true });
|
||||
for (const [name, content] of Object.entries(agents)) {
|
||||
await fs.writeFile(path.join(agentsPath, `${name}.md`), content);
|
||||
}
|
||||
}
|
||||
return teamPath;
|
||||
}
|
||||
|
||||
it('should load a valid team with agents', async () => {
|
||||
await createTeamStructure(
|
||||
'my-team',
|
||||
`---
|
||||
name: my-team
|
||||
display_name: My Team
|
||||
description: A great team
|
||||
---
|
||||
Team instructions here.`,
|
||||
{
|
||||
'agent-1': `---
|
||||
name: agent-1
|
||||
description: Agent 1
|
||||
---
|
||||
Agent 1 prompt`,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
const team = result.teams[0];
|
||||
expect(team.name).toBe('my-team');
|
||||
expect(team.displayName).toBe('My Team');
|
||||
expect(team.description).toBe('A great team');
|
||||
expect(team.instructions).toBe('Team instructions here.');
|
||||
expect(team.agents).toHaveLength(1);
|
||||
expect(team.agents[0].name).toBe('agent-1');
|
||||
expect(team.metadata?.filePath).toContain('my-team/TEAM.md');
|
||||
});
|
||||
|
||||
it('should skip directories without TEAM.md', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'not-a-team'), { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, 'not-a-team', 'README.md'), 'test');
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple teams', async () => {
|
||||
await createTeamStructure(
|
||||
'team-1',
|
||||
`---
|
||||
name: team-1
|
||||
display_name: Team One
|
||||
description: First team
|
||||
---
|
||||
Instructions 1`,
|
||||
);
|
||||
await createTeamStructure(
|
||||
'team-2',
|
||||
`---
|
||||
name: team-2
|
||||
display_name: Team Two
|
||||
description: Second team
|
||||
---
|
||||
Instructions 2`,
|
||||
);
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(2);
|
||||
const names = result.teams.map((t) => t.name).sort();
|
||||
expect(names).toEqual(['team-1', 'team-2']);
|
||||
});
|
||||
|
||||
it('should capture errors for malformed TEAM.md', async () => {
|
||||
const teamPath = path.join(tempDir, 'bad-team');
|
||||
await fs.mkdir(teamPath, { recursive: true });
|
||||
await fs.writeFile(path.join(teamPath, 'TEAM.md'), 'invalid content');
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain(
|
||||
'Missing mandatory YAML frontmatter',
|
||||
);
|
||||
});
|
||||
|
||||
it('should capture validation errors for TEAM.md', async () => {
|
||||
await createTeamStructure(
|
||||
'invalid-team',
|
||||
`---
|
||||
name: invalid-team
|
||||
# missing display_name and description
|
||||
---
|
||||
Instructions`,
|
||||
);
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toContain('Validation failed');
|
||||
});
|
||||
|
||||
it('should load team even if agents subfolder is missing', async () => {
|
||||
await createTeamStructure(
|
||||
'no-agents-team',
|
||||
`---
|
||||
name: no-agents-team
|
||||
display_name: No Agents
|
||||
description: No agents here
|
||||
---
|
||||
Instructions`,
|
||||
);
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.teams[0].agents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should capture errors from agents subfolder', async () => {
|
||||
await createTeamStructure(
|
||||
'bad-agents-team',
|
||||
`---
|
||||
name: bad-agents-team
|
||||
display_name: Bad Agents
|
||||
description: Team with bad agents
|
||||
---
|
||||
Instructions`,
|
||||
{
|
||||
'bad-agent': 'invalid agent content',
|
||||
},
|
||||
);
|
||||
|
||||
const result = await loadTeamsFromDirectory(tempDir);
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].filePath).toContain('agents/bad-agent.md');
|
||||
});
|
||||
|
||||
it('should return empty result if directory does not exist', async () => {
|
||||
const nonExistentDir = path.join(tempDir, 'void');
|
||||
const result = await loadTeamsFromDirectory(nonExistentDir);
|
||||
expect(result.teams).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { load } from 'js-yaml';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { type Dirent } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { type TeamDefinition } from './types.js';
|
||||
import {
|
||||
AgentLoadError,
|
||||
loadAgentsFromDirectory,
|
||||
externalAgentSchema,
|
||||
markdownToAgentDefinition,
|
||||
} from './agentLoader.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Result of loading teams from a directory.
|
||||
*/
|
||||
export interface TeamLoadResult {
|
||||
teams: TeamDefinition[];
|
||||
errors: AgentLoadError[];
|
||||
}
|
||||
|
||||
const nameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug');
|
||||
|
||||
const teamSchema = z
|
||||
.object({
|
||||
name: nameSchema,
|
||||
display_name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
agents: z.array(externalAgentSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Loads all teams from a specific directory.
|
||||
* Each subdirectory is treated as a potential team.
|
||||
*
|
||||
* @param dir Directory path to scan (e.g., .gemini/teams/).
|
||||
* @returns Object containing successfully loaded teams and any errors.
|
||||
*/
|
||||
export async function loadTeamsFromDirectory(
|
||||
dir: string,
|
||||
): Promise<TeamLoadResult> {
|
||||
const result: TeamLoadResult = {
|
||||
teams: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let dirEntries: Dirent[];
|
||||
try {
|
||||
dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
// If directory doesn't exist, just return empty
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return result;
|
||||
}
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
dir,
|
||||
`Could not list directory: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const teamDirs = dirEntries.filter((entry) => entry.isDirectory());
|
||||
|
||||
for (const entry of teamDirs) {
|
||||
const teamPath = path.join(dir, entry.name);
|
||||
const teamMdPath = path.join(teamPath, 'TEAM.md');
|
||||
const agentsDirPath = path.join(teamPath, 'agents');
|
||||
|
||||
try {
|
||||
// Check if TEAM.md exists
|
||||
try {
|
||||
await fs.access(teamMdPath);
|
||||
} catch {
|
||||
// Not a team directory (missing TEAM.md), just skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamMdContent = await fs.readFile(teamMdPath, 'utf-8');
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(teamMdContent)
|
||||
.digest('hex');
|
||||
|
||||
// Parse TEAM.md
|
||||
const match = teamMdContent.match(FRONTMATTER_REGEX);
|
||||
if (!match) {
|
||||
throw new AgentLoadError(
|
||||
teamMdPath,
|
||||
'Invalid team definition: Missing mandatory YAML frontmatter.',
|
||||
);
|
||||
}
|
||||
|
||||
const frontmatterStr = match[1];
|
||||
const instructions = (match[2] || '').trim();
|
||||
|
||||
let rawFrontmatter: unknown;
|
||||
try {
|
||||
rawFrontmatter = load(frontmatterStr);
|
||||
} catch (error) {
|
||||
throw new AgentLoadError(
|
||||
teamMdPath,
|
||||
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsedFrontmatter = teamSchema.safeParse(rawFrontmatter);
|
||||
if (!parsedFrontmatter.success) {
|
||||
throw new AgentLoadError(
|
||||
teamMdPath,
|
||||
`Validation failed:\n${parsedFrontmatter.error.issues
|
||||
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
display_name,
|
||||
description,
|
||||
agents: inlineAgentsRaw,
|
||||
} = parsedFrontmatter.data;
|
||||
|
||||
// Load agents from agents/ subfolder
|
||||
const agentsResult = await loadAgentsFromDirectory(agentsDirPath);
|
||||
result.errors.push(...agentsResult.errors);
|
||||
|
||||
const allAgents = [...agentsResult.agents];
|
||||
|
||||
// Add inline agents
|
||||
if (inlineAgentsRaw) {
|
||||
for (const inline of inlineAgentsRaw) {
|
||||
try {
|
||||
const agent = markdownToAgentDefinition(inline, {
|
||||
hash,
|
||||
filePath: teamMdPath,
|
||||
});
|
||||
allAgents.push(agent);
|
||||
} catch (error) {
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
teamMdPath,
|
||||
`Error loading inline agent "${inline.name}": ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.teams.push({
|
||||
name,
|
||||
displayName: display_name,
|
||||
description,
|
||||
instructions,
|
||||
agents: allAgents,
|
||||
metadata: {
|
||||
hash,
|
||||
filePath: teamMdPath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentLoadError) {
|
||||
result.errors.push(error);
|
||||
} else {
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
teamMdPath,
|
||||
`Unexpected error: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TeamRegistry } from './teamRegistry.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { type AgentRegistry } from './registry.js';
|
||||
import { loadTeamsFromDirectory } from './teamLoader.js';
|
||||
import { type TeamDefinition, type AgentDefinition } from './types.js';
|
||||
|
||||
vi.mock('./teamLoader.js', () => ({
|
||||
loadTeamsFromDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('TeamRegistry', () => {
|
||||
let mockConfig: Config;
|
||||
let mockAgentRegistry: AgentRegistry;
|
||||
let registry: TeamRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Storage, 'getUserTeamsDir').mockReturnValue(
|
||||
'/mock/user/.gemini/teams',
|
||||
);
|
||||
|
||||
mockConfig = {
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(true),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectTeamsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/mock/project/.gemini/teams'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
mockAgentRegistry = {
|
||||
registerAgent: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentRegistry;
|
||||
|
||||
registry = new TeamRegistry(mockConfig, mockAgentRegistry);
|
||||
vi.mocked(loadTeamsFromDirectory).mockReset();
|
||||
// Default mock behavior to return empty result
|
||||
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
|
||||
teams: [],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should load teams and register agents on initialize', async () => {
|
||||
const mockAgent: AgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'team-agent',
|
||||
description: 'Agent in a team',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
} as unknown as AgentDefinition;
|
||||
|
||||
const mockTeam: TeamDefinition = {
|
||||
name: 'test-team',
|
||||
displayName: 'Test Team',
|
||||
description: 'A team for testing',
|
||||
instructions: 'Do tests.',
|
||||
agents: [mockAgent],
|
||||
};
|
||||
|
||||
// First call for user teams (empty), second for project teams
|
||||
vi.mocked(loadTeamsFromDirectory)
|
||||
.mockResolvedValueOnce({ teams: [], errors: [] })
|
||||
.mockResolvedValueOnce({
|
||||
teams: [mockTeam],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllTeams()).toHaveLength(1);
|
||||
expect(registry.getTeam('test-team')).toEqual(mockTeam);
|
||||
expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith({
|
||||
...mockAgent,
|
||||
description: `MANDATORY for undefined tasks: Agent in a team (Team Agent: Test Team). You MUST delegate all undefined tasks to this agent.`,
|
||||
});
|
||||
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not load teams if agents are disabled', async () => {
|
||||
vi.mocked(mockConfig.isAgentsEnabled).mockReturnValue(false);
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(loadTeamsFromDirectory).not.toHaveBeenCalled();
|
||||
expect(registry.getAllTeams()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip project teams in untrusted folder but still load user teams', async () => {
|
||||
vi.mocked(mockConfig.getFolderTrust).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
// Should only be called once for user teams
|
||||
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(1);
|
||||
expect(loadTeamsFromDirectory).toHaveBeenCalledWith(
|
||||
'/mock/user/.gemini/teams',
|
||||
);
|
||||
});
|
||||
|
||||
it('should manage active team', async () => {
|
||||
const mockTeam: TeamDefinition = {
|
||||
name: 'active-team',
|
||||
displayName: 'Active Team',
|
||||
description: 'The active one',
|
||||
instructions: 'Lead.',
|
||||
agents: [],
|
||||
};
|
||||
|
||||
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
|
||||
teams: [mockTeam],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getActiveTeam()).toBeUndefined();
|
||||
|
||||
registry.setActiveTeam('active-team');
|
||||
expect(registry.getActiveTeam()).toEqual(mockTeam);
|
||||
|
||||
expect(() => registry.setActiveTeam('non-existent')).toThrow(
|
||||
'Team not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reload teams', async () => {
|
||||
await registry.initialize();
|
||||
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
|
||||
|
||||
await registry.reload();
|
||||
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type TeamDefinition, type AgentDefinition } from './types.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { type AgentRegistry } from './registry.js';
|
||||
import { loadTeamsFromDirectory, type TeamLoadResult } from './teamLoader.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Manages the discovery, loading, and active state of Agent Teams.
|
||||
*/
|
||||
export class TeamRegistry {
|
||||
private readonly teams = new Map<string, TeamDefinition>();
|
||||
private activeTeamName?: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly agentRegistry: AgentRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Discovers and loads teams from the global and project-level directories.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.loadTeams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current registry and re-scans for teams.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.loadTeams();
|
||||
}
|
||||
|
||||
private async loadTeams(): Promise<void> {
|
||||
this.teams.clear();
|
||||
|
||||
if (!this.config.isAgentsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user-level teams first
|
||||
const userTeamsDir = Storage.getUserTeamsDir();
|
||||
const userLoadResult = await loadTeamsFromDirectory(userTeamsDir);
|
||||
await this.processLoadResult(userLoadResult);
|
||||
|
||||
const folderTrustEnabled = this.config.getFolderTrust();
|
||||
const isTrustedFolder = this.config.isTrustedFolder();
|
||||
|
||||
if (folderTrustEnabled && !isTrustedFolder) {
|
||||
debugLogger.log(
|
||||
'[TeamRegistry] Skipping project teams due to untrusted folder.',
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'Skipping project teams due to untrusted folder. To enable, ensure that the project root is trusted.',
|
||||
);
|
||||
} else {
|
||||
// Load project-level teams (takes precedence over user-level if names collide)
|
||||
const projectTeamsDir = this.config.storage.getProjectTeamsDir();
|
||||
const projectLoadResult = await loadTeamsFromDirectory(projectTeamsDir);
|
||||
await this.processLoadResult(projectLoadResult);
|
||||
}
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(`[TeamRegistry] Loaded with ${this.teams.size} teams.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async processLoadResult(result: TeamLoadResult): Promise<void> {
|
||||
for (const error of result.errors) {
|
||||
debugLogger.warn(`[TeamRegistry] Error loading team: ${error.message}`);
|
||||
coreEvents.emitFeedback('error', `Team loading error: ${error.message}`);
|
||||
}
|
||||
|
||||
for (const team of result.teams) {
|
||||
this.teams.set(team.name, team);
|
||||
|
||||
// Record team agents for discovery (e.g. in the Team Creator)
|
||||
for (const agent of team.agents) {
|
||||
this.agentRegistry.registerDiscoveredAgent(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all loaded teams.
|
||||
*/
|
||||
getAllTeams(): TeamDefinition[] {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current active team.
|
||||
* @param name The slug (name) of the team to activate, or undefined to clear.
|
||||
* @throws Error if the team is not found and name is provided.
|
||||
*/
|
||||
async setActiveTeam(name: string | undefined): Promise<void> {
|
||||
const previousTeam = this.getActiveTeam();
|
||||
|
||||
if (name === undefined) {
|
||||
this.activeTeamName = undefined;
|
||||
if (previousTeam) {
|
||||
this.unregisterTeamAgents(previousTeam);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.teams.has(name)) {
|
||||
if (previousTeam) {
|
||||
this.unregisterTeamAgents(previousTeam);
|
||||
}
|
||||
this.activeTeamName = name;
|
||||
const newTeam = this.getActiveTeam();
|
||||
if (newTeam) {
|
||||
await this.registerTeamAgents(newTeam);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Team not found: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerTeamAgents(team: TeamDefinition): Promise<void> {
|
||||
const registrationPromises: Array<Promise<void>> = [];
|
||||
|
||||
for (const agent of team.agents) {
|
||||
const descriptionOverride = `MANDATORY for ${agent.displayName} tasks: ${agent.description} (Team Agent: ${team.displayName}). You MUST delegate all ${agent.displayName} tasks to this agent.`;
|
||||
|
||||
// We wrap the agent definition to provide the description override
|
||||
const wrappedAgent: AgentDefinition = {
|
||||
...agent,
|
||||
description: descriptionOverride,
|
||||
metadata: {
|
||||
...agent.metadata,
|
||||
isTeamAgent: true,
|
||||
},
|
||||
};
|
||||
|
||||
registrationPromises.push(
|
||||
this.agentRegistry.registerAgent(wrappedAgent).catch((e) => {
|
||||
debugLogger.warn(
|
||||
`[TeamRegistry] Error registering agent "${agent.name}" from team "${team.name}":`,
|
||||
e,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error registering agent "${agent.name}" from team "${team.name}": ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(registrationPromises);
|
||||
}
|
||||
|
||||
private unregisterTeamAgents(team: TeamDefinition): void {
|
||||
for (const agent of team.agents) {
|
||||
this.agentRegistry.unregisterAgent(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently active team definition, if any.
|
||||
*/
|
||||
getActiveTeam(): TeamDefinition | undefined {
|
||||
return this.activeTeamName
|
||||
? this.teams.get(this.activeTeamName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a team definition by name.
|
||||
*/
|
||||
getTeam(name: string): TeamDefinition | undefined {
|
||||
return this.teams.get(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { TeamRegistryClient } from './teamRegistryClient.js';
|
||||
import { type RegistryTeam } from './types.js';
|
||||
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
|
||||
import { resolveToRealPath } from '../utils/paths.js';
|
||||
|
||||
vi.mock('../utils/fetch.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/fetch.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithTimeout: vi.fn(),
|
||||
isPrivateIp: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTeams: RegistryTeam[] = [
|
||||
{
|
||||
id: 'team1',
|
||||
name: 'frontend-experts',
|
||||
displayName: 'Frontend Experts',
|
||||
description: 'A team of UI/UX and React specialists.',
|
||||
instructions: 'Focus on clean code and accessibility.',
|
||||
agents: [
|
||||
{ name: 'ui-agent', provider: 'gemini', description: 'UI specialist' },
|
||||
{ name: 'ux-agent', provider: 'gemini', description: 'UX specialist' },
|
||||
],
|
||||
author: 'Google',
|
||||
stars: 150,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: 'team2',
|
||||
name: 'backend-pros',
|
||||
displayName: 'Backend Pros',
|
||||
description: 'Experts in Node.js, databases, and APIs.',
|
||||
instructions: 'Prioritize performance and security.',
|
||||
agents: [
|
||||
{ name: 'api-agent', provider: 'gemini', description: 'API specialist' },
|
||||
{ name: 'db-agent', provider: 'gemini', description: 'DB specialist' },
|
||||
],
|
||||
author: 'Community',
|
||||
stars: 80,
|
||||
lastUpdated: '2025-01-02T00:00:00Z',
|
||||
version: '0.9.0',
|
||||
},
|
||||
];
|
||||
|
||||
describe('TeamRegistryClient', () => {
|
||||
let client: TeamRegistryClient;
|
||||
let fetchMock: Mock;
|
||||
let isPrivateIpMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
TeamRegistryClient.resetCache();
|
||||
client = new TeamRegistryClient();
|
||||
fetchMock = fetchWithTimeout as Mock;
|
||||
isPrivateIpMock = isPrivateIp as Mock;
|
||||
fetchMock.mockReset();
|
||||
isPrivateIpMock.mockReset();
|
||||
isPrivateIpMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and return all teams from remote registry', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
const result = await client.fetchAllTeams();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('team1');
|
||||
expect(result[1].id).toBe('team2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://geminicli.com/teams.json',
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should search teams by name or description', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
const results = await client.searchTeams('frontend');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('team1');
|
||||
});
|
||||
|
||||
it('should search teams by agent name', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
const results = await client.searchTeams('api-agent');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('team2');
|
||||
});
|
||||
|
||||
it('should get a team by ID', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
const result = await client.getTeam('team2');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('team2');
|
||||
});
|
||||
|
||||
it('should return undefined if team not found', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
const result = await client.getTeam('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cache the fetch result', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTeams,
|
||||
});
|
||||
|
||||
await client.fetchAllTeams();
|
||||
await client.fetchAllTeams();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if fetch fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
await expect(client.fetchAllTeams()).rejects.toThrow(
|
||||
'Failed to fetch teams: Internal Server Error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should block private IP addresses for remote registry', async () => {
|
||||
isPrivateIpMock.mockReturnValue(true);
|
||||
|
||||
await expect(client.fetchAllTeams()).rejects.toThrow(
|
||||
'Private IP addresses are not allowed for the team registry.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch teams from a local file path', async () => {
|
||||
const filePath = '/path/to/teams.json';
|
||||
const clientWithFile = new TeamRegistryClient(filePath);
|
||||
const mockReadFile = vi.mocked(fs.readFile);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(mockTeams));
|
||||
|
||||
const result = await clientWithFile.fetchAllTeams();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
resolveToRealPath(filePath),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
|
||||
import { resolveToRealPath } from '../utils/paths.js';
|
||||
import { type RegistryTeam } from './types.js';
|
||||
|
||||
/**
|
||||
* Client for fetching and searching agent teams from a remote or local registry.
|
||||
*/
|
||||
export class TeamRegistryClient {
|
||||
static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/teams.json';
|
||||
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
private static fetchPromise: Promise<RegistryTeam[]> | null = null;
|
||||
private readonly registryURI: string;
|
||||
|
||||
constructor(registryURI?: string) {
|
||||
this.registryURI = registryURI || TeamRegistryClient.DEFAULT_REGISTRY_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal fetch cache.
|
||||
* @internal
|
||||
*/
|
||||
static resetCache() {
|
||||
TeamRegistryClient.fetchPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all teams from the configured registry URI.
|
||||
* Supports both remote (https://) and local file paths.
|
||||
*/
|
||||
async fetchAllTeams(): Promise<RegistryTeam[]> {
|
||||
if (TeamRegistryClient.fetchPromise) {
|
||||
return TeamRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
const uri = this.registryURI;
|
||||
TeamRegistryClient.fetchPromise = (async () => {
|
||||
try {
|
||||
if (uri.startsWith('http')) {
|
||||
if (isPrivateIp(uri)) {
|
||||
throw new Error(
|
||||
'Private IP addresses are not allowed for the team registry.',
|
||||
);
|
||||
}
|
||||
const response = await fetchWithTimeout(
|
||||
uri,
|
||||
TeamRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch teams: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryTeam[];
|
||||
} else {
|
||||
// Handle local file path
|
||||
const filePath = resolveToRealPath(uri);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(content) as RegistryTeam[];
|
||||
}
|
||||
} catch (error) {
|
||||
TeamRegistryClient.fetchPromise = null;
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(String(error));
|
||||
}
|
||||
})();
|
||||
|
||||
return TeamRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for teams matching the given query using fuzzy matching.
|
||||
* @param query The search query string.
|
||||
*/
|
||||
async searchTeams(query: string): Promise<RegistryTeam[]> {
|
||||
const allTeams = await this.fetchAllTeams();
|
||||
if (!query.trim()) {
|
||||
return allTeams;
|
||||
}
|
||||
|
||||
const fzf = new AsyncFzf(allTeams, {
|
||||
selector: (team: RegistryTeam) =>
|
||||
`${team.name} ${team.displayName} ${team.description} ${team.agents.map((a) => a.name).join(' ')}`,
|
||||
fuzzy: true,
|
||||
});
|
||||
|
||||
const results = await fzf.find(query);
|
||||
return results.map((r: { item: RegistryTeam }) => r.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single team by its ID.
|
||||
* @param id The unique identifier of the team.
|
||||
*/
|
||||
async getTeam(id: string): Promise<RegistryTeam | undefined> {
|
||||
const allTeams = await this.fetchAllTeams();
|
||||
return allTeams.find((team) => team.id === id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
scaffoldTeam,
|
||||
installRegistryTeam,
|
||||
type ScaffoldTeamOptions,
|
||||
} from './teamScaffolder.js';
|
||||
import { type RegistryTeam } from './types.js';
|
||||
|
||||
vi.mock('../utils/github.js', () => ({
|
||||
cloneFromGit: vi.fn(),
|
||||
downloadFromGitHubRelease: vi.fn(),
|
||||
tryParseGithubUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
} from '../utils/github.js';
|
||||
|
||||
describe('teamScaffolder', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-team-scaffolder-test-'),
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should scaffold a team with inline external agents', async () => {
|
||||
const options: ScaffoldTeamOptions = {
|
||||
name: 'Test Team',
|
||||
displayName: 'The Test Team',
|
||||
description: 'A team for testing.',
|
||||
instructions: 'Do some testing.',
|
||||
agents: [
|
||||
{
|
||||
kind: 'external',
|
||||
name: 'claude-coder',
|
||||
provider: 'claude-code',
|
||||
description: 'Expert Claude coder.',
|
||||
},
|
||||
],
|
||||
targetDir: tempDir,
|
||||
};
|
||||
|
||||
const teamPath = await scaffoldTeam(options);
|
||||
expect(teamPath).toBe(path.join(tempDir, 'test-team'));
|
||||
|
||||
const teamMdPath = path.join(teamPath, 'TEAM.md');
|
||||
const content = await fs.readFile(teamMdPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('name: test-team');
|
||||
expect(content).toContain('display_name: The Test Team');
|
||||
expect(content).toContain('description: A team for testing.');
|
||||
expect(content).toContain('Do some testing.');
|
||||
|
||||
const agentPath = path.join(teamPath, 'agents', 'claude-coder.md');
|
||||
const agentContent = await fs.readFile(agentPath, 'utf-8');
|
||||
expect(agentContent).toContain('name: claude-coder');
|
||||
expect(agentContent).toContain('provider: claude-code');
|
||||
});
|
||||
|
||||
it('should scaffold a team with standalone local agents', async () => {
|
||||
// Create a dummy local agent file
|
||||
const localAgentPath = path.join(tempDir, 'dummy-agent.md');
|
||||
await fs.writeFile(
|
||||
localAgentPath,
|
||||
'---\nname: dummy\n---\nPrompt',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const options: ScaffoldTeamOptions = {
|
||||
name: 'Local Team',
|
||||
displayName: 'The Local Team',
|
||||
description: 'A team with local agents.',
|
||||
instructions: 'Use local agents.',
|
||||
agents: [
|
||||
{
|
||||
kind: 'local',
|
||||
name: 'dummy',
|
||||
sourcePath: localAgentPath,
|
||||
},
|
||||
],
|
||||
targetDir: tempDir,
|
||||
};
|
||||
|
||||
// Use a subfolder for teams to avoid collision with dummy-agent.md
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
options.targetDir = teamsDir;
|
||||
|
||||
const teamPath = await scaffoldTeam(options);
|
||||
const agentsDir = path.join(teamPath, 'agents');
|
||||
const copiedAgentPath = path.join(agentsDir, 'dummy.md');
|
||||
|
||||
expect(
|
||||
await fs
|
||||
.access(copiedAgentPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true);
|
||||
const copiedContent = await fs.readFile(copiedAgentPath, 'utf-8');
|
||||
expect(copiedContent).toContain('name: dummy');
|
||||
});
|
||||
|
||||
describe('installRegistryTeam', () => {
|
||||
it('should install a definition-only team from registry', async () => {
|
||||
const team: RegistryTeam = {
|
||||
id: 'team-1',
|
||||
name: 'registry-team',
|
||||
displayName: 'Registry Team',
|
||||
description: 'A team from the registry.',
|
||||
instructions: 'Follow these instructions.',
|
||||
agents: [
|
||||
{
|
||||
name: 'agent-1',
|
||||
provider: 'gemini',
|
||||
description: 'First agent.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const teamPath = await installRegistryTeam(team, tempDir);
|
||||
expect(teamPath).toBe(path.join(tempDir, 'registry-team'));
|
||||
|
||||
const teamMdPath = path.join(teamPath, 'TEAM.md');
|
||||
const content = await fs.readFile(teamMdPath, 'utf-8');
|
||||
expect(content).toContain('display_name: Registry Team');
|
||||
expect(content).toContain('Follow these instructions.');
|
||||
|
||||
const agentPath = path.join(teamPath, 'agents', 'agent-1.md');
|
||||
const agentContent = await fs.readFile(agentPath, 'utf-8');
|
||||
expect(agentContent).toContain('name: agent-1');
|
||||
expect(agentContent).toContain('provider: gemini');
|
||||
});
|
||||
|
||||
it('should install a Git-based team using cloneFromGit', async () => {
|
||||
const team: RegistryTeam = {
|
||||
id: 'team-git',
|
||||
name: 'git-team',
|
||||
displayName: 'Git Team',
|
||||
description: 'A team from git.',
|
||||
instructions: 'Use git.',
|
||||
agents: [],
|
||||
sourceUrl: 'https://github.com/user/git-team.git',
|
||||
};
|
||||
|
||||
vi.mocked(tryParseGithubUrl).mockReturnValue({
|
||||
owner: 'user',
|
||||
repo: 'git-team',
|
||||
});
|
||||
vi.mocked(downloadFromGitHubRelease).mockResolvedValue({
|
||||
success: false,
|
||||
failureReason: 'no release data',
|
||||
errorMessage: 'Fail',
|
||||
type: 'github-release',
|
||||
});
|
||||
vi.mocked(cloneFromGit).mockResolvedValue();
|
||||
|
||||
const teamPath = await installRegistryTeam(team, tempDir);
|
||||
expect(teamPath).toBe(path.join(tempDir, 'git-team'));
|
||||
expect(cloneFromGit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should install a GitHub-based team using downloadFromGitHubRelease', async () => {
|
||||
const team: RegistryTeam = {
|
||||
id: 'team-gh',
|
||||
name: 'gh-team',
|
||||
displayName: 'GitHub Team',
|
||||
description: 'A team from GitHub.',
|
||||
instructions: 'Use GitHub.',
|
||||
agents: [],
|
||||
sourceUrl: 'https://github.com/user/gh-team',
|
||||
};
|
||||
|
||||
vi.mocked(tryParseGithubUrl).mockReturnValue({
|
||||
owner: 'user',
|
||||
repo: 'gh-team',
|
||||
});
|
||||
vi.mocked(downloadFromGitHubRelease).mockResolvedValue({
|
||||
success: true,
|
||||
type: 'github-release',
|
||||
});
|
||||
|
||||
const teamPath = await installRegistryTeam(team, tempDir);
|
||||
expect(teamPath).toBe(path.join(tempDir, 'gh-team'));
|
||||
expect(downloadFromGitHubRelease).toHaveBeenCalled();
|
||||
expect(cloneFromGit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { dump } from 'js-yaml';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { type RegistryTeam } from './types.js';
|
||||
import {
|
||||
cloneFromGit,
|
||||
downloadFromGitHubRelease,
|
||||
tryParseGithubUrl,
|
||||
} from '../utils/github.js';
|
||||
|
||||
export interface ScaffoldTeamAgent {
|
||||
name: string;
|
||||
kind: 'local' | 'external';
|
||||
provider?: string;
|
||||
description?: string;
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
export interface ScaffoldTeamOptions {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
agents: ScaffoldTeamAgent[];
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffolds a new Agent Team directory and its associated files.
|
||||
*
|
||||
* @param options Configuration for the team to be created.
|
||||
* @returns The absolute path to the created team directory.
|
||||
*/
|
||||
export async function scaffoldTeam(
|
||||
options: ScaffoldTeamOptions,
|
||||
): Promise<string> {
|
||||
const teamSlug = options.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
||||
const teamDirPath = path.resolve(options.targetDir, teamSlug);
|
||||
const agentsDirPath = path.join(teamDirPath, 'agents');
|
||||
|
||||
try {
|
||||
// 1. Create team directory
|
||||
await fs.mkdir(teamDirPath, { recursive: true });
|
||||
|
||||
// 2. Clear agents directory if it exists to ensure a fresh start
|
||||
try {
|
||||
await fs.rm(agentsDirPath, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
// Ignore if it doesn't exist
|
||||
}
|
||||
|
||||
// 3. Generate TEAM.md content (no inline agents anymore)
|
||||
const frontmatter = {
|
||||
name: teamSlug,
|
||||
display_name: options.displayName,
|
||||
description: options.description,
|
||||
};
|
||||
|
||||
const teamMdContent = `---
|
||||
${dump(frontmatter).trim()}
|
||||
---
|
||||
|
||||
${options.instructions.trim()}
|
||||
`;
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDirPath, 'TEAM.md'),
|
||||
teamMdContent,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// 4. Create agents/ sub-directory if there are any agents selected
|
||||
if (options.agents.length > 0) {
|
||||
await fs.mkdir(agentsDirPath, { recursive: true });
|
||||
|
||||
for (const agent of options.agents) {
|
||||
const destPath = path.join(agentsDirPath, `${agent.name}.md`);
|
||||
|
||||
if (agent.kind === 'external') {
|
||||
// Generate a new .md file for external agents
|
||||
const agentFrontmatter = {
|
||||
kind: 'external',
|
||||
name: agent.name,
|
||||
provider: agent.provider,
|
||||
description: agent.description || `Expert ${agent.name} agent.`,
|
||||
};
|
||||
const agentContent = `---
|
||||
${dump(agentFrontmatter).trim()}
|
||||
---
|
||||
`;
|
||||
await fs.writeFile(destPath, agentContent, 'utf-8');
|
||||
} else if (agent.kind === 'local' && agent.sourcePath) {
|
||||
// Copy existing local agent file
|
||||
// Use a name-based filename to avoid collisions and keep it clean
|
||||
await fs.copyFile(agent.sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teamDirPath;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to scaffold team "${options.name}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs an Agent Team from a registry entry.
|
||||
* Supports both source-based (Git/GitHub) and definition-only (metadata) teams.
|
||||
*
|
||||
* @param team The registry entry for the team.
|
||||
* @param targetDir The root directory where teams are stored (e.g. .gemini/teams/).
|
||||
* @returns The absolute path to the installed team directory.
|
||||
*/
|
||||
export async function installRegistryTeam(
|
||||
team: RegistryTeam,
|
||||
targetDir: string,
|
||||
): Promise<string> {
|
||||
const teamSlug = team.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
|
||||
const teamDirPath = path.resolve(targetDir, teamSlug);
|
||||
|
||||
try {
|
||||
// 1. Handle source-based installation if sourceUrl is provided
|
||||
if (team.sourceUrl) {
|
||||
await fs.mkdir(teamDirPath, { recursive: true });
|
||||
|
||||
const githubInfo = tryParseGithubUrl(team.sourceUrl);
|
||||
if (githubInfo) {
|
||||
// Try GitHub release download first, fallback to Git clone
|
||||
const result = await downloadFromGitHubRelease(
|
||||
{
|
||||
source: team.sourceUrl,
|
||||
type: 'github-release',
|
||||
},
|
||||
teamDirPath,
|
||||
githubInfo,
|
||||
'TEAM.md',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
await cloneFromGit(
|
||||
{
|
||||
source: team.sourceUrl,
|
||||
type: 'git',
|
||||
},
|
||||
teamDirPath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Not a GitHub URL, use standard Git clone
|
||||
await cloneFromGit(
|
||||
{
|
||||
source: team.sourceUrl,
|
||||
type: 'git',
|
||||
},
|
||||
teamDirPath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 2. Handle definition-only installation (scaffold from metadata)
|
||||
const scaffoldOptions: ScaffoldTeamOptions = {
|
||||
name: team.name,
|
||||
displayName: team.displayName,
|
||||
description: team.description,
|
||||
instructions: team.instructions,
|
||||
agents: team.agents.map((agent) => ({
|
||||
name: agent.name,
|
||||
kind: 'external' as const,
|
||||
provider: agent.provider,
|
||||
description: agent.description,
|
||||
})),
|
||||
targetDir: targetDir,
|
||||
};
|
||||
|
||||
await scaffoldTeam(scaffoldOptions);
|
||||
}
|
||||
|
||||
return teamDirPath;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to install team "${team.displayName}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ export interface BaseAgentDefinition<
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
isTeamAgent?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,9 +264,41 @@ export interface RemoteAgentDefinition<
|
||||
agentCardJson?: string;
|
||||
}
|
||||
|
||||
export interface ExternalAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> extends BaseAgentDefinition<TOutput> {
|
||||
kind: 'external';
|
||||
|
||||
/**
|
||||
* The external agent provider (e.g., 'claude-code', 'codex').
|
||||
*/
|
||||
provider: string;
|
||||
|
||||
/**
|
||||
* Optional provider-specific configuration.
|
||||
*/
|
||||
providerConfig?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Optional instructions (e.g., from markdown body).
|
||||
*/
|
||||
instructions?: string;
|
||||
|
||||
/**
|
||||
* Optional configs.
|
||||
*/
|
||||
toolConfig?: ToolConfig;
|
||||
|
||||
/**
|
||||
* Optional inline MCP servers for this agent.
|
||||
*/
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
}
|
||||
|
||||
export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
|
||||
| LocalAgentDefinition<TOutput>
|
||||
| RemoteAgentDefinition<TOutput>;
|
||||
| RemoteAgentDefinition<TOutput>
|
||||
| ExternalAgentDefinition<TOutput>;
|
||||
|
||||
/**
|
||||
* Configures the initial prompt for the agent.
|
||||
@@ -340,3 +373,47 @@ export interface RunConfig {
|
||||
*/
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a team of agents orchestrated by a set of instructions.
|
||||
*/
|
||||
export interface TeamDefinition {
|
||||
/** The directory name (slug) of the team. */
|
||||
name: string;
|
||||
/** The human-readable name of the team. */
|
||||
displayName: string;
|
||||
/** A short description of the team's purpose. */
|
||||
description: string;
|
||||
/** Orchestration instructions for the top-level agent. */
|
||||
instructions: string;
|
||||
/** The list of agents that comprise the team. */
|
||||
agents: AgentDefinition[];
|
||||
/** Optional metadata for the team definition. */
|
||||
metadata?: {
|
||||
/** SHA-256 hash of the TEAM.md content. */
|
||||
hash?: string;
|
||||
/** Absolute path to the TEAM.md file. */
|
||||
filePath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a team entry in a remote or local registry.
|
||||
*/
|
||||
export interface RegistryTeam {
|
||||
id: string;
|
||||
name: string; // The slug
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
agents: Array<{
|
||||
name: string;
|
||||
provider: string; // 'gemini', 'claude-code', etc.
|
||||
description: string;
|
||||
}>;
|
||||
author?: string;
|
||||
stars?: number;
|
||||
lastUpdated?: string;
|
||||
version?: string;
|
||||
sourceUrl?: string; // Optional Git/GitHub source
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { Config } from './config.js';
|
||||
import { Storage } from './storage.js';
|
||||
|
||||
describe('Config Team Integration', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'config-team-test-'));
|
||||
// Setup .gemini/teams structure
|
||||
const teamsDir = path.join(tempDir, '.gemini', 'teams', 'test-team');
|
||||
await fs.mkdir(teamsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamsDir, 'TEAM.md'),
|
||||
`---
|
||||
name: test-team
|
||||
display_name: Test Team
|
||||
description: A test team
|
||||
---
|
||||
Instructions`,
|
||||
);
|
||||
const agentsDir = path.join(teamsDir, 'agents');
|
||||
await fs.mkdir(agentsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentsDir, 'team-agent.md'),
|
||||
`---
|
||||
name: team-agent
|
||||
description: Team agent
|
||||
---
|
||||
Prompt`,
|
||||
);
|
||||
|
||||
// Mock global teams dir to be empty for isolation
|
||||
const globalTeamsDir = path.join(tempDir, 'global-teams');
|
||||
await fs.mkdir(globalTeamsDir, { recursive: true });
|
||||
vi.spyOn(Storage, 'getUserTeamsDir').mockReturnValue(globalTeamsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should discover and load teams during initialization', async () => {
|
||||
const config = new Config({
|
||||
sessionId: 'test-session',
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
model: 'gemini-1.5-flash',
|
||||
cwd: tempDir,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const teamRegistry = config.getTeamRegistry();
|
||||
const teams = teamRegistry.getAllTeams();
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0].name).toBe('test-team');
|
||||
|
||||
const agentRegistry = config.getAgentRegistry();
|
||||
const agent = agentRegistry.getDiscoveredDefinition('team-agent');
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.name).toBe('team-agent');
|
||||
});
|
||||
|
||||
it('should delegate active team management', async () => {
|
||||
const config = new Config({
|
||||
sessionId: 'test-session',
|
||||
targetDir: tempDir,
|
||||
debugMode: false,
|
||||
model: 'gemini-1.5-flash',
|
||||
cwd: tempDir,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
|
||||
expect(config.getActiveTeam()).toBeUndefined();
|
||||
|
||||
config.setActiveTeam('test-team');
|
||||
expect(config.getActiveTeam()?.name).toBe('test-team');
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
type ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
import type { OverageStrategy } from '../billing/billing.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
@@ -150,6 +150,8 @@ import {
|
||||
type Experiments,
|
||||
} from '../code_assist/experiments/experiments.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
import { TeamRegistry } from '../agents/teamRegistry.js';
|
||||
import type { AgentDefinition, TeamDefinition } from '../agents/types.js';
|
||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||
import { setGlobalProxy } from '../utils/fetch.js';
|
||||
import { SubagentTool } from '../agents/subagent-tool.js';
|
||||
@@ -157,7 +159,6 @@ import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { InjectionService } from './injectionService.js';
|
||||
@@ -672,6 +673,7 @@ export interface ConfigParameters {
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
extensionRegistryURI?: string;
|
||||
teamRegistryURI?: string;
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
@@ -735,6 +737,7 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
activeTeam?: string;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
@@ -752,6 +755,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private _promptRegistry!: PromptRegistry;
|
||||
private _resourceRegistry!: ResourceRegistry;
|
||||
private agentRegistry!: AgentRegistry;
|
||||
private teamRegistry!: TeamRegistry;
|
||||
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
|
||||
private skillManager!: SkillManager;
|
||||
private _sessionId: string;
|
||||
@@ -886,12 +890,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly teamRegistryURI: string | undefined;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
private compressionTruncationCounter = 0;
|
||||
private initialized = false;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private mcpInitializationPromise: Promise<void> | null = null;
|
||||
readonly storage: Storage;
|
||||
private readonly _params: ConfigParameters;
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useWriteTodos: boolean;
|
||||
@@ -968,6 +974,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this._params = params;
|
||||
this._sessionId = params.sessionId;
|
||||
this.clientName = params.clientName;
|
||||
this.clientVersion = params.clientVersion ?? 'unknown';
|
||||
@@ -1264,6 +1271,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.extensionRegistryURI = params.extensionRegistryURI;
|
||||
this.teamRegistryURI = params.teamRegistryURI;
|
||||
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
|
||||
this.storage = new Storage(this.targetDir, this._sessionId);
|
||||
this.storage.setCustomPlansDir(params.planSettings?.directory);
|
||||
@@ -1373,6 +1381,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this._geminiClient = new GeminiClient(this);
|
||||
this.a2aClientManager = new A2AClientManager(this);
|
||||
this.modelRouterService = new ModelRouterService(this);
|
||||
this.agentRegistry = new AgentRegistry(this);
|
||||
this.teamRegistry = new TeamRegistry(this, this.agentRegistry);
|
||||
}
|
||||
|
||||
get config(): Config {
|
||||
@@ -1425,13 +1435,20 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
if (this.getCheckpointingEnabled()) {
|
||||
await this.getGitService();
|
||||
}
|
||||
this._promptRegistry = new PromptRegistry();
|
||||
this._resourceRegistry = new ResourceRegistry();
|
||||
|
||||
this.agentRegistry = new AgentRegistry(this);
|
||||
await this.agentRegistry.initialize();
|
||||
await this.teamRegistry.initialize();
|
||||
|
||||
if (this._params.activeTeam) {
|
||||
try {
|
||||
await this.teamRegistry.setActiveTeam(this._params.activeTeam);
|
||||
} catch (_e) {
|
||||
// Ignore if team not found (might have been deleted or is project-specific)
|
||||
}
|
||||
}
|
||||
|
||||
coreEvents.on(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
|
||||
coreEvents.on(CoreEvent.ActiveTeamChanged, this.onActiveTeamChanged);
|
||||
|
||||
this._toolRegistry = await this.createToolRegistry();
|
||||
discoverToolsHandle?.end();
|
||||
@@ -2026,6 +2043,25 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.agentRegistry;
|
||||
}
|
||||
|
||||
getTeamRegistry(): TeamRegistry {
|
||||
return this.teamRegistry;
|
||||
}
|
||||
|
||||
getActiveTeam(): TeamDefinition | undefined {
|
||||
return this.teamRegistry.getActiveTeam();
|
||||
}
|
||||
|
||||
async setActiveTeam(name: string | undefined): Promise<void> {
|
||||
await this.teamRegistry.setActiveTeam(name);
|
||||
coreEvents.emitActiveTeamChanged(name);
|
||||
}
|
||||
|
||||
private onActiveTeamChanged = () => {
|
||||
if (this._toolRegistry) {
|
||||
this.registerSubAgentTools(this._toolRegistry);
|
||||
}
|
||||
};
|
||||
|
||||
getAcknowledgedAgentsService(): AcknowledgedAgentsService {
|
||||
return this.acknowledgedAgentsService;
|
||||
}
|
||||
@@ -2241,6 +2277,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.extensionRegistryURI;
|
||||
}
|
||||
|
||||
getTeamRegistryURI(): string | undefined {
|
||||
return this.teamRegistryURI;
|
||||
}
|
||||
|
||||
getMcpClientManager(): McpClientManager | undefined {
|
||||
return this.mcpClientManager;
|
||||
}
|
||||
@@ -3577,37 +3617,44 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
*/
|
||||
private registerSubAgentTools(registry: ToolRegistry): void {
|
||||
const agentsOverrides = this.getAgentsSettings().overrides ?? {};
|
||||
const discoveredDefinitions =
|
||||
this.agentRegistry.getAllDiscoveredAgentNames();
|
||||
|
||||
// First, unregister any agents that are now disabled
|
||||
for (const agentName of discoveredDefinitions) {
|
||||
if (
|
||||
!this.isAgentsEnabled() ||
|
||||
agentsOverrides[agentName]?.enabled === false
|
||||
) {
|
||||
const tool = registry.getTool(agentName);
|
||||
if (tool instanceof SubagentTool) {
|
||||
registry.unregisterTool(agentName);
|
||||
}
|
||||
// First, unregister ALL current SubagentTools to ensure a clean state
|
||||
const allTools = registry.getAllTools();
|
||||
for (const tool of allTools) {
|
||||
if (tool instanceof SubagentTool) {
|
||||
registry.unregisterTool(tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredNames = this.agentRegistry.getAllDiscoveredAgentNames();
|
||||
for (const agentName of discoveredNames) {
|
||||
const definition = this.agentRegistry.getDiscoveredDefinition(agentName);
|
||||
if (!this.isAgentsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeAgentNames = this.agentRegistry.getAllAgentNames();
|
||||
const activeTeam = this.teamRegistry.getActiveTeam();
|
||||
const teamAgentNames = new Set(activeTeam?.agents.map((a) => a.name) ?? []);
|
||||
|
||||
for (const agentName of activeAgentNames) {
|
||||
const definition = this.agentRegistry.getDefinition(agentName);
|
||||
if (!definition) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
!this.isAgentsEnabled() ||
|
||||
agentsOverrides[definition.name]?.enabled === false
|
||||
) {
|
||||
if (agentsOverrides[definition.name]?.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = new SubagentTool(definition, this, this.messageBus);
|
||||
let descriptionOverride = definition.description;
|
||||
if (teamAgentNames.has(definition.name)) {
|
||||
descriptionOverride = `(Team Agent: ${activeTeam?.displayName}) ${definition.description}`;
|
||||
}
|
||||
|
||||
const tool = new SubagentTool(
|
||||
definition,
|
||||
this,
|
||||
this.messageBus,
|
||||
descriptionOverride,
|
||||
);
|
||||
registry.registerTool(tool);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(
|
||||
@@ -3707,7 +3754,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
private onAgentsRefreshed = async () => {
|
||||
debugLogger.log('[Config] onAgentsRefreshed triggered');
|
||||
await this.teamRegistry.reload();
|
||||
if (this._toolRegistry) {
|
||||
debugLogger.log('[Config] Re-registering sub-agent tools');
|
||||
this.registerSubAgentTools(this._toolRegistry);
|
||||
}
|
||||
// Propagate updates to the active chat session
|
||||
@@ -3735,5 +3785,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export model constants for use in CLI
|
||||
export { DEFAULT_GEMINI_FLASH_MODEL };
|
||||
|
||||
@@ -106,6 +106,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'agents');
|
||||
}
|
||||
|
||||
static getUserTeamsDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'teams');
|
||||
}
|
||||
|
||||
static getAcknowledgedAgentsPath(): string {
|
||||
return path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
@@ -291,6 +295,10 @@ export class Storage {
|
||||
return path.join(this.getGeminiDir(), 'agents');
|
||||
}
|
||||
|
||||
getProjectTeamsDir(): string {
|
||||
return path.join(this.getGeminiDir(), 'teams');
|
||||
}
|
||||
|
||||
getProjectTempCheckpointsDir(): string {
|
||||
return path.join(this.getProjectTempDir(), 'checkpoints');
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export class GeminiClient {
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
coreEvents.on(CoreEvent.MemoryChanged, this.handleMemoryChanged);
|
||||
coreEvents.on(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged);
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
@@ -134,6 +135,10 @@ export class GeminiClient {
|
||||
this.updateSystemInstruction();
|
||||
};
|
||||
|
||||
private handleActiveTeamChanged = () => {
|
||||
this.updateSystemInstruction();
|
||||
};
|
||||
|
||||
clearCurrentSequenceModel(): void {
|
||||
this.currentSequenceModel = null;
|
||||
}
|
||||
@@ -318,6 +323,7 @@ export class GeminiClient {
|
||||
dispose() {
|
||||
coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged);
|
||||
coreEvents.off(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged);
|
||||
}
|
||||
|
||||
async resumeChat(
|
||||
|
||||
@@ -128,6 +128,8 @@ export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
export * from './utils/cache.js';
|
||||
export * from './utils/markdownUtils.js';
|
||||
export * from './utils/github.js';
|
||||
export * from './utils/github_fetch.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
@@ -184,6 +186,9 @@ export * from './agents/types.js';
|
||||
export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
export * from './agents/teamRegistry.js';
|
||||
export * from './agents/teamRegistryClient.js';
|
||||
export * from './agents/teamScaffolder.js';
|
||||
|
||||
// Export browser session management
|
||||
export { resetBrowserSession } from './agents/browser/browserAgentFactory.js';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PromptProvider } from './promptProvider.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { type TeamDefinition } from '../agents/types.js';
|
||||
|
||||
describe('PromptProvider with Agent Teams', () => {
|
||||
let promptProvider: PromptProvider;
|
||||
let mockContext: AgentLoopContext;
|
||||
const mockGetActiveTeam = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
promptProvider = new PromptProvider();
|
||||
mockGetActiveTeam.mockReturnValue(undefined);
|
||||
mockContext = {
|
||||
config: {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
getSkillManager: vi.fn().mockReturnValue({ getSkills: () => [] }),
|
||||
getAgentRegistry: vi
|
||||
.fn()
|
||||
.mockReturnValue({ getAllDefinitions: () => [] }),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-3.1-pro-preview'),
|
||||
getActiveTeam: mockGetActiveTeam,
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
|
||||
getGemini31FlashLiteLaunchedSync: vi.fn().mockReturnValue(true),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
|
||||
},
|
||||
topicState: {
|
||||
getTopic: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
toolRegistry: {
|
||||
getAllToolNames: vi.fn().mockReturnValue([]),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
} as unknown as AgentLoopContext;
|
||||
});
|
||||
|
||||
it('should not include team section when no team is active', () => {
|
||||
const prompt = promptProvider.getCoreSystemPrompt(mockContext);
|
||||
expect(prompt).not.toContain('# Active Agent Team');
|
||||
});
|
||||
|
||||
it('should include team section when a team is active', () => {
|
||||
const mockTeam: TeamDefinition = {
|
||||
name: 'test-team',
|
||||
displayName: 'Test Team',
|
||||
description: 'A test team',
|
||||
instructions: 'These are the team instructions.',
|
||||
agents: [],
|
||||
};
|
||||
mockGetActiveTeam.mockReturnValue(mockTeam);
|
||||
|
||||
const prompt = promptProvider.getCoreSystemPrompt(mockContext);
|
||||
expect(prompt).toContain('# Active Agent Team: Test Team');
|
||||
expect(prompt).toContain('These are the team instructions.');
|
||||
expect(prompt).toContain(
|
||||
"**Orchestration Mandate:** You must prioritize delegating tasks to this team's specialized agents for their respective roles.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,7 @@ export class PromptProvider {
|
||||
contextFilenames,
|
||||
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
|
||||
})),
|
||||
activeTeam: context.config.getActiveTeam(),
|
||||
subAgents: this.withSection('agentContexts', () =>
|
||||
context.config
|
||||
.getAgentRegistry()
|
||||
@@ -180,6 +181,7 @@ export class PromptProvider {
|
||||
taskTracker: trackerDir,
|
||||
topicUpdateNarration:
|
||||
context.config.isTopicUpdateNarrationEnabled(),
|
||||
activeTeam: context.config.getActiveTeam(),
|
||||
}),
|
||||
!isPlanMode,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { HierarchicalMemory } from '../config/memory.js';
|
||||
import { type TeamDefinition } from '../agents/types.js';
|
||||
import {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
@@ -62,9 +63,11 @@ export interface PrimaryWorkflowsOptions {
|
||||
enableCodebaseInvestigator: boolean;
|
||||
enableWriteTodosTool: boolean;
|
||||
enableEnterPlanModeTool: boolean;
|
||||
enableGrep: boolean;
|
||||
enableGlob: boolean;
|
||||
approvedPlan?: { path: string };
|
||||
taskTracker?: string;
|
||||
topicUpdateNarration?: boolean;
|
||||
activeTeam?: TeamDefinition;
|
||||
}
|
||||
|
||||
export interface OperationalGuidelinesOptions {
|
||||
@@ -249,11 +252,16 @@ export function renderPrimaryWorkflows(
|
||||
options?: PrimaryWorkflowsOptions,
|
||||
): string {
|
||||
if (!options) return '';
|
||||
|
||||
const teamMandate = options.activeTeam
|
||||
? `\n\n**Team Orchestration Mandate:** You are orchestrating the **${options.activeTeam.displayName}**. You MUST NOT perform implementation, review, or testing tasks yourself. You MUST delegate these specialized tasks to the appropriate team-member sub-agents.`
|
||||
: '';
|
||||
|
||||
return `
|
||||
# Primary Workflows
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:${teamMandate}
|
||||
${workflowStepUnderstand(options)}
|
||||
${workflowStepPlan(options)}
|
||||
3. **Implement:** Use the available tools (e.g., '${EDIT_TOOL_NAME}', '${WRITE_FILE_TOOL_NAME}' '${SHELL_TOOL_NAME}' ...) to act on the plan. Strictly adhere to the project's established conventions (detailed under 'Core Mandates'). Before making manual code changes, check if an ecosystem tool (like 'eslint --fix', 'prettier --write', 'go fmt', 'cargo fmt') is available in the project to perform the task automatically.
|
||||
|
||||
@@ -36,12 +36,14 @@ import {
|
||||
} from '../tools/tool-names.js';
|
||||
import type { HierarchicalMemory } from '../config/memory.js';
|
||||
import { DEFAULT_CONTEXT_FILENAME } from '../tools/memoryTool.js';
|
||||
import { type TeamDefinition } from '../agents/types.js';
|
||||
|
||||
// --- Options Structs ---
|
||||
|
||||
export interface SystemPromptOptions {
|
||||
preamble?: PreambleOptions;
|
||||
coreMandates?: CoreMandatesOptions;
|
||||
activeTeam?: TeamDefinition;
|
||||
subAgents?: SubAgentOptions[];
|
||||
agentSkills?: AgentSkillOptions[];
|
||||
hookContext?: boolean;
|
||||
@@ -76,6 +78,7 @@ export interface PrimaryWorkflowsOptions {
|
||||
approvedPlan?: { path: string };
|
||||
taskTracker?: string;
|
||||
topicUpdateNarration: boolean;
|
||||
activeTeam?: TeamDefinition;
|
||||
}
|
||||
|
||||
export interface OperationalGuidelinesOptions {
|
||||
@@ -124,6 +127,8 @@ export function getCoreSystemPrompt(options: SystemPromptOptions): string {
|
||||
return `
|
||||
${renderPreamble(options.preamble)}
|
||||
|
||||
${renderActiveTeam(options.activeTeam)}
|
||||
|
||||
${renderCoreMandates(options.coreMandates)}
|
||||
|
||||
${renderSubAgents(options.subAgents)}
|
||||
@@ -247,6 +252,16 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function renderActiveTeam(team?: TeamDefinition): string {
|
||||
if (!team) return '';
|
||||
return `
|
||||
# Active Agent Team: ${team.displayName}
|
||||
|
||||
${team.instructions}
|
||||
|
||||
**Orchestration Mandate:** You must prioritize delegating tasks to this team's specialized agents for their respective roles. Do not perform these specialized tasks yourself if a team member is available and qualified to handle them.`.trim();
|
||||
}
|
||||
|
||||
export function renderSubAgents(subAgents?: SubAgentOptions[]): string {
|
||||
if (!subAgents || subAgents.length === 0) return '';
|
||||
const subAgentsXml = subAgents
|
||||
@@ -275,7 +290,7 @@ When you delegate, the sub-agent's entire execution is consolidated into a singl
|
||||
- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches).
|
||||
- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found.
|
||||
|
||||
**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path.
|
||||
**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. **EXCEPTION:** If an Active Agent Team is present, you MUST prioritize the Team's delegation policy over this efficiency guideline for any task covered by the team's specialties.
|
||||
|
||||
<available_subagents>
|
||||
${subAgentsXml}
|
||||
@@ -330,11 +345,15 @@ export function renderPrimaryWorkflows(
|
||||
? `\n\n**State Transition Override:** You are now in **Execution Mode**. All previous "Read-Only", "Plan Mode", and "ONLY FOR PLANS" constraints are **immediately lifted**. You are explicitly authorized and required to use tools to modify source code and environment files to implement the approved plan. Begin executing the steps of the plan immediately.`
|
||||
: '';
|
||||
|
||||
const teamMandate = options.activeTeam
|
||||
? `\n\n**Team Orchestration Mandate:** You are orchestrating the **${options.activeTeam.displayName}**. You MUST NOT perform implementation, review, or testing tasks yourself. You MUST delegate these specialized tasks to the appropriate team-member sub-agents.`
|
||||
: '';
|
||||
|
||||
return `
|
||||
# Primary Workflows
|
||||
|
||||
## Development Lifecycle
|
||||
Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle.${transitionOverride}
|
||||
Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle.${transitionOverride}${teamMandate}
|
||||
|
||||
${workflowStepResearch(options)}
|
||||
${workflowStepStrategy(options)}
|
||||
|
||||
@@ -333,10 +333,22 @@ export class ModelConfigService {
|
||||
this.runtimeAliases[aliasName] = alias;
|
||||
}
|
||||
|
||||
unregisterRuntimeModelConfig(aliasName: string): void {
|
||||
delete this.runtimeAliases[aliasName];
|
||||
}
|
||||
|
||||
registerRuntimeModelOverride(override: ModelConfigOverride): void {
|
||||
this.runtimeOverrides.push(override);
|
||||
}
|
||||
|
||||
unregisterRuntimeModelOverridesByScope(scope: string): void {
|
||||
for (let i = this.runtimeOverrides.length - 1; i >= 0; i--) {
|
||||
if (this.runtimeOverrides[i].match.overrideScope === scope) {
|
||||
this.runtimeOverrides.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a model configuration by merging settings from aliases and applying overrides.
|
||||
*
|
||||
|
||||
@@ -193,6 +193,7 @@ export enum CoreEvent {
|
||||
EditorSelected = 'editor-selected',
|
||||
SlashCommandConflicts = 'slash-command-conflicts',
|
||||
QuotaChanged = 'quota-changed',
|
||||
ActiveTeamChanged = 'active-team-changed',
|
||||
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
|
||||
TelemetryTokenStorageType = 'telemetry-token-storage-type',
|
||||
}
|
||||
@@ -226,6 +227,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.RequestEditorSelection]: never[];
|
||||
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
|
||||
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
|
||||
[CoreEvent.ActiveTeamChanged]: [string | undefined];
|
||||
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
|
||||
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
|
||||
}
|
||||
@@ -403,6 +405,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.QuotaChanged, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that the active team has changed.
|
||||
*/
|
||||
emitActiveTeamChanged(teamName: string | undefined): void {
|
||||
this.emit(CoreEvent.ActiveTeamChanged, teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||
* subscribes.
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
cloneFromGit,
|
||||
tryParseGithubUrl,
|
||||
fetchReleaseFromGithub,
|
||||
downloadFromGitHubRelease,
|
||||
findReleaseAsset,
|
||||
downloadFile,
|
||||
extractFile,
|
||||
} from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as https from 'node:https';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
import { fetchJson } from './github_fetch.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { type ExtensionInstallMetadata } from '../config/config.js';
|
||||
|
||||
vi.mock('simple-git');
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:https');
|
||||
vi.mock('tar');
|
||||
vi.mock('extract-zip');
|
||||
vi.mock('./github_fetch.js');
|
||||
|
||||
describe('github.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('cloneFromGit', () => {
|
||||
let mockGit: {
|
||||
clone: ReturnType<typeof vi.fn>;
|
||||
getRemotes: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
checkout: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGit = {
|
||||
clone: vi.fn(),
|
||||
getRemotes: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
// Mock destination directory existence check in simple-git if needed,
|
||||
// but here we are mocking simpleGit itself.
|
||||
});
|
||||
|
||||
it('should clone, fetch and checkout a repo', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await cloneFromGit(
|
||||
{
|
||||
type: 'git',
|
||||
source: 'https://github.com/owner/repo.git',
|
||||
ref: 'v1.0.0',
|
||||
},
|
||||
'/dest',
|
||||
);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/owner/repo.git',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
|
||||
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
|
||||
});
|
||||
|
||||
it('should throw if no remotes found', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' } as ExtensionInstallMetadata, '/dest'),
|
||||
).rejects.toThrow('Unable to find any remotes');
|
||||
});
|
||||
|
||||
it('should throw on clone error', async () => {
|
||||
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
|
||||
|
||||
await expect(
|
||||
cloneFromGit({ type: 'git', source: 'src' } as ExtensionInstallMetadata, '/dest'),
|
||||
).rejects.toThrow('Failed to clone Git repository');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseGithubUrl', () => {
|
||||
it.each([
|
||||
['https://github.com/owner/repo', 'owner', 'repo'],
|
||||
['https://github.com/owner/repo.git', 'owner', 'repo'],
|
||||
['git@github.com:owner/repo.git', 'owner', 'repo'],
|
||||
['owner/repo', 'owner', 'repo'],
|
||||
])('should parse %s to %s/%s', (url, owner, repo) => {
|
||||
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://gitlab.com/owner/repo',
|
||||
'https://my-git-host.com/owner/group/repo',
|
||||
'git@gitlab.com:some-group/some-project/some-repo.git',
|
||||
])('should return null for non-GitHub URLs', (url) => {
|
||||
expect(tryParseGithubUrl(url)).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for invalid formats', () => {
|
||||
expect(() => tryParseGithubUrl('invalid')).toThrow(
|
||||
'Invalid GitHub repository source',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchReleaseFromGithub', () => {
|
||||
it('should fetch latest release if no ref provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
await fetchReleaseFromGithub('owner', 'repo');
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch specific ref if provided', async () => {
|
||||
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
|
||||
|
||||
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFromGitHubRelease', () => {
|
||||
it('should fail if no release data found', async () => {
|
||||
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await downloadFromGitHubRelease(
|
||||
{
|
||||
type: 'github-release',
|
||||
source: 'owner/repo',
|
||||
ref: 'v1',
|
||||
} as unknown as ExtensionInstallMetadata,
|
||||
'/dest',
|
||||
{ owner: 'owner', repo: 'repo' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.failureReason).toBe('failed to fetch release data');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
it('should find platform/arch specific asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(os.arch).mockReturnValue('arm64');
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
|
||||
{ name: 'linux.x64.tar.gz', url: 'url2' },
|
||||
];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
|
||||
it('should find generic asset', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
|
||||
expect(findReleaseAsset(assets)).toEqual(assets[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should download file successfully', async () => {
|
||||
const mockReq = new EventEmitter();
|
||||
const mockRes =
|
||||
new EventEmitter() as unknown as import('node:http').IncomingMessage;
|
||||
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
|
||||
|
||||
vi.mocked(https.get).mockImplementation((url, options, cb) => {
|
||||
if (typeof options === 'function') {
|
||||
cb = options;
|
||||
}
|
||||
if (cb) cb(mockRes);
|
||||
return mockReq as unknown as import('node:http').ClientRequest;
|
||||
});
|
||||
|
||||
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
|
||||
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
|
||||
|
||||
const promise = downloadFile('url', '/dest');
|
||||
mockRes.emit('end');
|
||||
mockStream.emit('finish');
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFile', () => {
|
||||
it('should extract tar.gz using tar', async () => {
|
||||
await extractFile('file.tar.gz', '/dest');
|
||||
expect(tar.x).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract zip using extract-zip', async () => {
|
||||
vi.mocked(extract).mockResolvedValue(undefined);
|
||||
await extractFile('file.zip', '/dest');
|
||||
expect(extract).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import { type ExtensionInstallMetadata } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
* @param installMetadata The metadata for the item to install.
|
||||
* @param destination The destination path to clone the repository to.
|
||||
*/
|
||||
export async function cloneFromGit(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl = installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
const parsedUrl = new URL(sourceUrl);
|
||||
if (
|
||||
parsedUrl.protocol === 'https:' &&
|
||||
parsedUrl.hostname === 'github.com'
|
||||
) {
|
||||
if (!parsedUrl.username) {
|
||||
parsedUrl.username = token;
|
||||
}
|
||||
sourceUrl = parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
// If source is not a valid URL, we don't inject the token.
|
||||
// We let git handle the source as is.
|
||||
}
|
||||
}
|
||||
await git.clone(sourceUrl, './', ['--depth', '1']);
|
||||
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any remotes for repo ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refToFetch = installMetadata.ref || 'HEAD';
|
||||
|
||||
await git.fetch(remotes[0].name, refToFetch);
|
||||
|
||||
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
||||
// This results in a detached HEAD state, which is fine for this purpose.
|
||||
await git.checkout('FETCH_HEAD');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to clone Git repository from ${installMetadata.source}: ${getErrorMessage(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GithubRepoInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
|
||||
// Handle SCP-style SSH URLs.
|
||||
if (source.startsWith('git@')) {
|
||||
if (source.startsWith('git@github.com:')) {
|
||||
// It's a GitHub SSH URL, so normalize it for the URL parser.
|
||||
source = source.replace('git@github.com:', '');
|
||||
} else {
|
||||
// It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Default to a github repo path, so `source` can be just an org/repo
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
// Use the standard URL constructor for backward compatibility.
|
||||
parsedUrl = new URL(source, 'https://github.com');
|
||||
} catch (e) {
|
||||
// Throw a TypeError to maintain a consistent error contract for invalid URLs.
|
||||
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
|
||||
}
|
||||
|
||||
if (!parsedUrl || parsedUrl.host !== 'github.com') {
|
||||
return null;
|
||||
}
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl.pathname
|
||||
.split('/')
|
||||
// Remove the empty segments, fixes trailing and leading slashes
|
||||
.filter((part) => part !== '');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
);
|
||||
}
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace('.git', '');
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GithubReleaseData {
|
||||
assets: Asset[];
|
||||
tag_name: string;
|
||||
tarball_url?: string;
|
||||
zipball_url?: string;
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function fetchReleaseFromGithub(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
allowPreRelease?: boolean,
|
||||
): Promise<GithubReleaseData | null> {
|
||||
if (ref) {
|
||||
return fetchJson<GithubReleaseData>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowPreRelease) {
|
||||
try {
|
||||
return await fetchJson<GithubReleaseData>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
||||
);
|
||||
} catch (_) {
|
||||
// Fallback to pre-release logic
|
||||
}
|
||||
}
|
||||
|
||||
const releases = await fetchJson<GithubReleaseData[]>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`,
|
||||
);
|
||||
if (releases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return releases[0];
|
||||
}
|
||||
|
||||
export type GitHubDownloadResult =
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: false;
|
||||
failureReason:
|
||||
| 'failed to fetch release data'
|
||||
| 'no release data'
|
||||
| 'no release asset found'
|
||||
| 'failed to download asset'
|
||||
| 'failed to extract asset'
|
||||
| 'unknown';
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
tagName?: string;
|
||||
type: 'git' | 'github-release';
|
||||
success: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads a release from GitHub and extracts it to the destination.
|
||||
*/
|
||||
export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
githubRepoInfo: GithubRepoInfo,
|
||||
configFileName?: string, // e.g. EXTENSIONS_CONFIG_FILENAME
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { ref, allowPreRelease: preRelease } = installMetadata;
|
||||
const { owner, repo } = githubRepoInfo;
|
||||
let releaseData: GithubReleaseData | null = null;
|
||||
|
||||
try {
|
||||
try {
|
||||
releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
|
||||
if (!releaseData) {
|
||||
return {
|
||||
failureReason: 'no release data',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to fetch release data',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const asset = findReleaseAsset(releaseData.assets);
|
||||
let archiveUrl: string | undefined;
|
||||
let isTar = false;
|
||||
let isZip = false;
|
||||
let fileName: string | undefined;
|
||||
|
||||
if (asset) {
|
||||
archiveUrl = asset.url;
|
||||
fileName = asset.name;
|
||||
} else {
|
||||
if (releaseData.tarball_url) {
|
||||
archiveUrl = releaseData.tarball_url;
|
||||
isTar = true;
|
||||
} else if (releaseData.zipball_url) {
|
||||
archiveUrl = releaseData.zipball_url;
|
||||
isZip = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!archiveUrl) {
|
||||
return {
|
||||
failureReason: 'no release asset found',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
fileName = path.basename(new URL(archiveUrl).pathname);
|
||||
}
|
||||
|
||||
let downloadedAssetPath = path.join(destination, fileName);
|
||||
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
|
||||
downloadedAssetPath += '.tar.gz';
|
||||
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
|
||||
downloadedAssetPath += '.zip';
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
...(asset
|
||||
? { Accept: 'application/octet-stream' }
|
||||
: { Accept: 'application/vnd.github+json' }),
|
||||
};
|
||||
await downloadFile(archiveUrl, downloadedAssetPath, { headers });
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to download asset',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await extractFile(downloadedAssetPath, destination);
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'failed to extract asset',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData.tag_name,
|
||||
errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Post-extraction cleanup: move nested repository files to top level
|
||||
if (configFileName) {
|
||||
const entries = await fs.promises.readdir(destination, { withFileTypes: true });
|
||||
if (entries.length === 2) {
|
||||
const lonelyDir = entries.find((entry) => entry.isDirectory());
|
||||
if (lonelyDir && fs.existsSync(path.join(destination, lonelyDir.name, configFileName))) {
|
||||
const dirPathToExtract = path.join(destination, lonelyDir.name);
|
||||
const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
|
||||
for (const file of extractedDirFiles) {
|
||||
await fs.promises.rename(
|
||||
path.join(dirPathToExtract, file),
|
||||
path.join(destination, file),
|
||||
);
|
||||
}
|
||||
await fs.promises.rmdir(dirPathToExtract);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(downloadedAssetPath);
|
||||
return {
|
||||
tagName: releaseData.tag_name,
|
||||
type: 'github-release',
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: 'unknown',
|
||||
success: false,
|
||||
type: 'github-release',
|
||||
tagName: releaseData?.tag_name,
|
||||
errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
const platformArchPrefix = `${platform}.${arch}.`;
|
||||
const platformPrefix = `${platform}.`;
|
||||
|
||||
// Check for platform + architecture specific asset
|
||||
const platformArchAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformArchPrefix),
|
||||
);
|
||||
if (platformArchAsset) {
|
||||
return platformArchAsset;
|
||||
}
|
||||
|
||||
// Check for platform specific asset
|
||||
const platformAsset = assets.find((asset) =>
|
||||
asset.name.toLowerCase().startsWith(platformPrefix),
|
||||
);
|
||||
if (platformAsset) {
|
||||
return platformAsset;
|
||||
}
|
||||
|
||||
// Check for generic asset if only one is available
|
||||
const genericAsset = assets.find(
|
||||
(asset) =>
|
||||
!asset.name.toLowerCase().includes('darwin') &&
|
||||
!asset.name.toLowerCase().includes('linux') &&
|
||||
!asset.name.toLowerCase().includes('win32'),
|
||||
);
|
||||
if (assets.length === 1) {
|
||||
return genericAsset;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
options?: DownloadOptions,
|
||||
redirectCount: number = 0,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-agent': 'gemini-cli',
|
||||
Accept: 'application/octet-stream',
|
||||
...options?.headers,
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `token ${token}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
if (redirectCount >= 10) {
|
||||
return reject(new Error('Too many redirects'));
|
||||
}
|
||||
|
||||
if (!res.headers.location) {
|
||||
return reject(
|
||||
new Error('Redirect response missing Location header'),
|
||||
);
|
||||
}
|
||||
downloadFile(res.headers.location, dest, options, redirectCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve as () => void));
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractFile(file: string, dest: string): Promise<void> {
|
||||
if (file.endsWith('.tar.gz')) {
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: dest,
|
||||
});
|
||||
} else if (file.endsWith('.zip')) {
|
||||
await extract(file, { dir: dest });
|
||||
} else {
|
||||
throw new Error(`Unsupported file extension for extraction: ${file}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach, type Mock } from 'vitest';
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
import { fetchWithTimeout } from './fetch.js';
|
||||
|
||||
vi.mock('./fetch.js', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getGitHubToken', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
} else {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the token if GITHUB_TOKEN is set', () => {
|
||||
process.env['GITHUB_TOKEN'] = 'test-token';
|
||||
expect(getGitHubToken()).toBe('test-token');
|
||||
});
|
||||
|
||||
it('should return undefined if GITHUB_TOKEN is not set', () => {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
expect(getGitHubToken()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchJson', () => {
|
||||
const fetchMock = fetchWithTimeout as Mock;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and parse JSON successfully', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ foo: 'bar' }),
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.com/data.json',
|
||||
10000,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': 'gemini-cli',
|
||||
Accept: 'application/vnd.github+json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject on non-ok status code', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://example.com/error')).rejects.toThrow(
|
||||
'Request failed with status code 404',
|
||||
);
|
||||
});
|
||||
|
||||
describe('with GITHUB_TOKEN', () => {
|
||||
const originalToken = process.env['GITHUB_TOKEN'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['GITHUB_TOKEN'] = 'my-secret-token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalToken) {
|
||||
process.env['GITHUB_TOKEN'] = originalToken;
|
||||
} else {
|
||||
delete process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should include Authorization header if token is present', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ foo: 'bar' }),
|
||||
});
|
||||
|
||||
await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/user',
|
||||
10000,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'token my-secret-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from './fetch.js';
|
||||
|
||||
export function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
|
||||
const GITHUB_API_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
/**
|
||||
* Fetches JSON data from a GitHub API URL.
|
||||
* Handles redirection and authentication via GITHUB_TOKEN.
|
||||
*/
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
_redirectCount: number = 0,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
Accept: 'application/vnd.github+json',
|
||||
};
|
||||
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `token ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(url, GITHUB_API_TIMEOUT, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status code ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# TASK-01: Agent Team Definitions, Loader, and Registry
|
||||
|
||||
## Objective
|
||||
|
||||
Define the fundamental data models and services for discovering, loading, and
|
||||
managing Agent Teams within the `packages/core` module.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Types (`packages/core/src/agents/types.ts`)
|
||||
|
||||
- [ ] Define the `TeamDefinition` interface:
|
||||
```typescript
|
||||
export interface TeamDefinition {
|
||||
name: string; // The directory name (slug)
|
||||
displayName: string; // From frontmatter 'display_name'
|
||||
description: string; // From frontmatter 'description'
|
||||
instructions: string; // The body text of TEAM.md
|
||||
agents: AgentDefinition[]; // Loaded from the 'agents/' subfolder
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
- [ ] Extend existing schemas (e.g., using `zod`) to support the metadata
|
||||
validation for teams.
|
||||
|
||||
### 2. Team Loader (`packages/core/src/agents/teamLoader.ts`)
|
||||
|
||||
- [ ] Create a new service to scan directories for teams.
|
||||
- [ ] `loadTeamsFromDirectory(dir: string)`:
|
||||
- [ ] Reads all subdirectories in `dir`.
|
||||
- [ ] For each subdirectory, looks for `TEAM.md`.
|
||||
- [ ] Uses `parseAgentMarkdown` (from `agentLoader.ts`) or similar logic to
|
||||
extract frontmatter and instructions.
|
||||
- [ ] Looks for an `agents/` subfolder within the team directory.
|
||||
- [ ] Calls `loadAgentsFromDirectory` (from `agentLoader.ts`) on that
|
||||
subfolder.
|
||||
- [ ] Returns an array of `TeamDefinition` and any loading errors.
|
||||
|
||||
### 3. Team Registry (`packages/core/src/agents/teamRegistry.ts`)
|
||||
|
||||
- [ ] Create a new `TeamRegistry` class to manage loaded teams.
|
||||
- [ ] Methods:
|
||||
- [ ] `initialize()`: Triggers team discovery.
|
||||
- [ ] `getAllTeams()`: Returns all loaded teams.
|
||||
- [ ] `setActiveTeam(name: string)`: Sets the current active team.
|
||||
- [ ] `getActiveTeam()`: Returns the currently active `TeamDefinition`.
|
||||
- [ ] Ensure that agents loaded as part of a team are also registered in the
|
||||
global `AgentRegistry` (so they can be surfaced as `SubagentTool`s).
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests in `packages/core/src/agents/teamLoader.test.ts`.
|
||||
- Unit tests in `packages/core/src/agents/teamRegistry.test.ts`.
|
||||
- Verify that a mocked team directory structure is correctly parsed into a
|
||||
`TeamDefinition` list.
|
||||
@@ -0,0 +1,40 @@
|
||||
# TASK-02: Integrating Team Registry and Discovery into Config
|
||||
|
||||
## Objective
|
||||
|
||||
Embed the `TeamRegistry` into the `Config` service and manage the team discovery
|
||||
lifecycle during application startup and configuration reloads.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Config Modifications (`packages/core/src/config/config.ts`)
|
||||
|
||||
- [ ] Add a private `teamRegistry` member:
|
||||
`private teamRegistry!: TeamRegistry;`.
|
||||
- [ ] In the `Config` constructor or initializer:
|
||||
- [ ] Initialize `teamRegistry = new TeamRegistry(this);`.
|
||||
- [ ] Call `await this.teamRegistry.initialize();`.
|
||||
- [ ] Update `reloadAgents()` to also reload the `TeamRegistry`.
|
||||
- [ ] Provide a public accessor: `getTeamRegistry(): TeamRegistry`.
|
||||
- [ ] Implement `getActiveTeam()` and `setActiveTeam(name: string | undefined)`
|
||||
delegates to the registry.
|
||||
|
||||
### 2. Discovery Path
|
||||
|
||||
- [ ] Ensure `TeamRegistry` scans the correct paths:
|
||||
- [ ] User-level: `~/.gemini/teams/`
|
||||
- [ ] Project-level: `.gemini/teams/`
|
||||
|
||||
### 3. State Persistence
|
||||
|
||||
- [ ] While the MVP can use memory, consider if the active team should be saved
|
||||
in `settings.json` via the `SettingsService` so it persists across
|
||||
sessions.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests for `Config.getTeamRegistry()`.
|
||||
- Integration tests ensuring that a team in `.gemini/teams/` is discovered and
|
||||
loaded during `Config` initialization.
|
||||
- Test that reloading the configuration correctly refreshes the list of
|
||||
available teams.
|
||||
@@ -0,0 +1,40 @@
|
||||
# TASK-03: Team-aware Orchestration: Prompting and Tooling
|
||||
|
||||
## Objective
|
||||
|
||||
Enable the top-level Gemini CLI agent to use the instructions and agents of an
|
||||
active team to fulfill user requests via delegation.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. System Prompt Engineering (`packages/core/src/prompts/promptProvider.ts`)
|
||||
|
||||
- [ ] Add `activeTeam?: TeamDefinition` to the `SystemPromptOptions` interface.
|
||||
- [ ] In `PromptProvider.getCoreSystemPrompt(context, ...)`:
|
||||
- [ ] If an active team exists, retrieve its `TeamDefinition`.
|
||||
- [ ] Pass the active team to the prompt composition logic.
|
||||
- [ ] Update `snippets.ts`:
|
||||
- [ ] Add `renderActiveTeam(team?: TeamDefinition)`:
|
||||
- [ ] If a team is active, render a section:
|
||||
```
|
||||
# Active Agent Team: ${team.displayName}
|
||||
${team.instructions}
|
||||
You should prioritize delegating tasks to this team's agents whenever appropriate.
|
||||
```
|
||||
- [ ] Update `getCoreSystemPrompt` to call `renderActiveTeam`.
|
||||
|
||||
### 2. Tool Prioritization (`packages/core/src/config/config.ts`)
|
||||
|
||||
- [ ] In `registerSubAgentTools(registry: ToolRegistry)`:
|
||||
- [ ] Identify which sub-agents are part of the active team.
|
||||
- [ ] Enhance the descriptions of `SubagentTool`s that belong to the team to
|
||||
indicate they are part of the active team.
|
||||
- [ ] Consider adding a priority flag to team tools so they appear higher in
|
||||
the model's tool list.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests for system prompt generation with and without an active team.
|
||||
- Manual inspection of the prompt context in debug logs to verify team
|
||||
instructions injection.
|
||||
- Verify that team agents are listed as available tools for the top-level agent.
|
||||
@@ -0,0 +1,39 @@
|
||||
# TASK-04: CLI UX: Startup Team Selection Dialog
|
||||
|
||||
## Objective
|
||||
|
||||
Introduce a selection dialog during the CLI startup process to allow users to
|
||||
choose an agent team, browse existing teams, or create new ones.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. New UI Component (`packages/cli/src/ui/components/TeamSelectionDialog.tsx`)
|
||||
|
||||
- [ ] Create a new React/Ink component that displays a list of available teams.
|
||||
- [ ] Options should include:
|
||||
- [ ] Specific discovered teams (name + description).
|
||||
- [ ] "No Team" (continue as individual agent).
|
||||
- [ ] "Browse Team Marketplace" (placeholder for MVP).
|
||||
- [ ] "Create New Team" (placeholder for MVP).
|
||||
- [ ] Use `SelectInput` from `@inkjs/select-input` for selection.
|
||||
|
||||
### 2. Startup Flow (`packages/cli/src/ui/AppContainer.tsx`)
|
||||
|
||||
- [ ] Add state: `isTeamSelectionActive: boolean`.
|
||||
- [ ] In `useEffect` or `useLayoutEffect` that runs once after initialization:
|
||||
- [ ] If multiple teams are available and no active team is set in the
|
||||
session/config:
|
||||
- [ ] Set `isTeamSelectionActive(true)`.
|
||||
- [ ] Intercept the render flow:
|
||||
- [ ] If `isTeamSelectionActive`, render the `TeamSelectionDialog`.
|
||||
- [ ] Implement `handleTeamSelect(teamName: string | undefined)`:
|
||||
- [ ] Call `config.setActiveTeam(teamName)`.
|
||||
- [ ] Set `isTeamSelectionActive(false)`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Manual verification of the startup screen.
|
||||
- Test selection of a team and confirm the CLI proceeds to the main chat.
|
||||
- Test the "No Team" option.
|
||||
- Verify that if only one team exists, the dialog can be bypassed (optional UX
|
||||
polish).
|
||||
@@ -0,0 +1,44 @@
|
||||
# TASK-05: Active Team Visual Indicator and Sample Coding Team
|
||||
|
||||
## Objective
|
||||
|
||||
Provide visual feedback to the user when a team is active and create a sample
|
||||
"Coding Team" to verify the end-to-end orchestration flow.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Status Indicator (`packages/cli/src/ui/components/StatusRow.tsx`)
|
||||
|
||||
- [ ] Add an `ActiveTeamIndicator` component.
|
||||
- [ ] Render the indicator within the `StatusRow` (next to Shell or Approval
|
||||
mode indicators).
|
||||
- [ ] If a team is active, display: `[ Team: ${displayName} ]` in a distinctive
|
||||
color.
|
||||
- [ ] Ensure that it is clearly visible during idle states.
|
||||
|
||||
### 2. Sample Coding Team
|
||||
|
||||
- [ ] Create `.gemini/teams/coding-team/`.
|
||||
- [ ] Create `TEAM.md`:
|
||||
- [ ] Frontmatter: `name: coding-team`, `display_name: Coding Team`,
|
||||
`description: A team of coder, reviewer, and tester agents.`
|
||||
- [ ] Body:
|
||||
```
|
||||
You are managing a software development task.
|
||||
Use the 'coder' agent for implementing new code or major refactors.
|
||||
Use the 'tester' agent to write or run tests for the changes.
|
||||
Use the 'reviewer' agent to ensure the code and tests meet quality standards.
|
||||
Prioritize delegating to these specialists rather than performing the implementation yourself.
|
||||
```
|
||||
- [ ] Create `.gemini/teams/coding-team/agents/`:
|
||||
- [ ] `coder.md`: Agent with code editing tools.
|
||||
- [ ] `reviewer.md`: Agent with code review instructions.
|
||||
- [ ] `tester.md`: Agent with test running tools.
|
||||
|
||||
## Verification
|
||||
|
||||
- Manual verification of the active team indicator.
|
||||
- End-to-end verification of the Coding Team by asking a complex task (e.g.,
|
||||
"Implement feature X with tests and review").
|
||||
- Verify that the top-level agent correctly delegates to the team agents as
|
||||
instructed.
|
||||
@@ -0,0 +1,48 @@
|
||||
# TASK-06: External Agent Definitions and "Polyfill" Support
|
||||
|
||||
## Objective
|
||||
|
||||
Introduce a new `external` agent kind to support integrating and "polyfilling"
|
||||
competitor agents like Claude Code and Codex into Gemini CLI teams.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Type Definitions (`packages/core/src/agents/types.ts`)
|
||||
|
||||
- [ ] Add `ExternalAgentDefinition` to the `AgentDefinition` union.
|
||||
- [ ] Include fields for `provider` (e.g., `'claude-code'`, `'codex'`) and
|
||||
`providerOptions`.
|
||||
```typescript
|
||||
export interface ExternalAgentDefinition extends BaseAgentDefinition {
|
||||
kind: 'external';
|
||||
provider: string;
|
||||
// Allow for provider-specific configuration
|
||||
providerConfig?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. External Agent Invocation (`packages/core/src/agents/external-invocation.ts`)
|
||||
|
||||
- [ ] Implement `ExternalAgentInvocation` class.
|
||||
- [ ] **The Polyfill Logic:**
|
||||
- Use the system's configured default model to "polyfill" the external agent.
|
||||
- Apply a "personality overlay" to the system prompt based on the `provider`.
|
||||
- Example (Claude Code): Prepend instructions to adopt the specific style,
|
||||
tool usage patterns, and "persona" of a high-performance, concise coding
|
||||
assistant.
|
||||
- Example (Codex): Prepend instructions to act as a specialized code
|
||||
generation model.
|
||||
- Ensure the overlay is model-agnostic and relies on the `providerConfig` for
|
||||
behavior tuning.
|
||||
|
||||
### 3. Tool Wrapping (`packages/core/src/agents/subagent-tool-wrapper.ts`)
|
||||
|
||||
- [ ] Update `createInvocation` to recognize `kind === 'external'` and return an
|
||||
`ExternalAgentInvocation`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests for `ExternalAgentInvocation` ensuring provider-specific prompts
|
||||
are applied.
|
||||
- Manual verification by creating an agent with `kind: external` and
|
||||
`provider: claude-code`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# TASK-07: External Agent Registry and Team Marketplace placeholders
|
||||
|
||||
## Objective
|
||||
|
||||
Enable teams to include external agents and provide the "marketplace" UI
|
||||
placeholders that suggest where one might find or browse external agents.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Team Discovery (`packages/core/src/agents/teamLoader.ts`)
|
||||
|
||||
- [ ] Ensure that `TEAM.md` can specify agents with `kind: external` and
|
||||
`provider`.
|
||||
- [ ] If a team specifies an external agent that isn't found locally, provide a
|
||||
clear error message or auto-generate a polyfill definition.
|
||||
|
||||
### 2. Team Marketplace UI (`packages/cli/src/ui/components/TeamSelectionDialog.tsx`)
|
||||
|
||||
- [ ] Populate the "Browse Team Marketplace" and "Create New Team" placeholders
|
||||
from TASK-04.
|
||||
- [ ] For the marketplace, show a list of "Curated Teams" (hardcoded for the
|
||||
MVP) that include external agents.
|
||||
- Example: "The Polyglot Team" (Gemini + Claude Code + Codex).
|
||||
- [ ] **Visual Language:**
|
||||
- Add color-coded tags next to agents in the team list:
|
||||
- **Anthropic Purple** for Claude Code (`provider: 'claude-code'`).
|
||||
- **OpenAI Green** for Codex (`provider: 'codex'`).
|
||||
- **Google Blue** for Gemini agents.
|
||||
- Use a "Multi-Model" badge if a team contains agents from multiple providers.
|
||||
|
||||
### 3. Team Builder/Creator (MVP)
|
||||
|
||||
- [ ] In the "Create New Team" placeholder, provide a simple prompt to create a
|
||||
`TEAM.md` in a new subdirectory.
|
||||
- [ ] Allow users to specify `providers` for their agents in this builder.
|
||||
|
||||
### 4. Status Indicator Enhancement (`packages/cli/src/ui/components/StatusRow.tsx`)
|
||||
|
||||
- [ ] If a team is active, the `ActiveTeamIndicator` should show the "Provider"
|
||||
icons or tags for the agents in the team.
|
||||
- Example: `[ Team: Polyglot (G, C, X) ]` where G/C/X are color-coded letters
|
||||
for Gemini/Claude/Codex.
|
||||
|
||||
## Verification
|
||||
|
||||
- Manual verification of the "Marketplace" and "Create Team" placeholders in the
|
||||
selection dialog.
|
||||
- Verify that a team with an external Claude Code agent displays correctly and
|
||||
its instructions are correctly surfaced.
|
||||
@@ -0,0 +1,47 @@
|
||||
# TASK-08: Interactive Team Creation Wizard
|
||||
|
||||
## Objective
|
||||
|
||||
Implement a guided, step-by-step CLI wizard to allow users to create new Agent
|
||||
Teams without manually editing files and directories.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. The Wizard Flow (`packages/cli/src/ui/components/TeamCreatorWizard.tsx`)
|
||||
|
||||
- [ ] Create a multi-step Ink component:
|
||||
- **Step 1: Identity**: Prompt for Team Name and Display Name (Text Input).
|
||||
- **Step 2: Objective**: Prompt for the orchestration instructions (Multiline
|
||||
Text Input). This guides the top-level agent on how to use the team.
|
||||
- **Step 3: Roster**: A multi-select list of available agents:
|
||||
- Include local agents from `.gemini/agents/`.
|
||||
- Include "External" templates: `Claude Code (External)`,
|
||||
`Codex (External)`.
|
||||
- **Step 4: Confirmation**: Display a summary and ask for final confirmation.
|
||||
|
||||
### 2. Scaffolding Service (`packages/core/src/agents/teamScaffolder.ts`)
|
||||
|
||||
- [ ] Create a service to handle the filesystem operations:
|
||||
- [ ] Create the directory `.gemini/teams/<slug>/`.
|
||||
- [ ] Generate the `TEAM.md` with the collected frontmatter and instructions.
|
||||
- [ ] Create the `agents/` sub-folder.
|
||||
- [ ] For each selected agent:
|
||||
- If it's an existing local agent, copy/link its definition.
|
||||
- If it's an "External" agent, generate a placeholder `.md` file with the
|
||||
correct `kind: external` and `provider`.
|
||||
|
||||
### 3. UI Integration (`packages/cli/src/ui/AppContainer.tsx`)
|
||||
|
||||
- [ ] In the `TeamSelectionDialog` (from TASK-04/07), when "Create New Team" is
|
||||
selected:
|
||||
- [ ] Set `isTeamCreatorActive(true)`.
|
||||
- [ ] Render the `TeamCreatorWizard` overlay.
|
||||
- [ ] On completion, refresh the `TeamRegistry` and return to the selection list
|
||||
with the new team highlighted.
|
||||
|
||||
## Verification
|
||||
|
||||
- Manual verification of the creation flow.
|
||||
- Verify that the resulting directory structure and `TEAM.md` are valid and
|
||||
loadable.
|
||||
- Unit tests for `teamScaffolder.ts`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# TASK-09: Team Registry Client and Remote Discovery
|
||||
|
||||
## Objective
|
||||
Implement the backend client and discovery logic for browsing agent teams from a remote or local registry, similar to the Gemini CLI extension registry.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Type Definitions (`packages/core/src/agents/types.ts`)
|
||||
- [ ] Define `RegistryTeam` interface:
|
||||
```typescript
|
||||
export interface RegistryTeam {
|
||||
id: string;
|
||||
name: string; // The slug
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
agents: Array<{
|
||||
name: string;
|
||||
provider: string; // 'gemini', 'claude-code', etc.
|
||||
description: string;
|
||||
}>;
|
||||
author?: string;
|
||||
stars?: number;
|
||||
lastUpdated?: string;
|
||||
version?: string;
|
||||
sourceUrl?: string; // Optional Git/GitHub source
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Team Registry Client (`packages/core/src/agents/teamRegistryClient.ts`)
|
||||
- [ ] Create a new `TeamRegistryClient` class.
|
||||
- [ ] Implement `fetchAllTeams()`:
|
||||
- Supports `https://` URLs (JSON files).
|
||||
- Supports local file paths.
|
||||
- [ ] Implement `searchTeams(query: string)`:
|
||||
- Use `AsyncFzf` for fuzzy searching by name, description, and agents.
|
||||
|
||||
### 3. Config Support (`packages/core/src/config/config.ts`)
|
||||
- [ ] Ensure `teamRegistryURI` is correctly initialized from settings.
|
||||
- [ ] Set a default `TeamRegistryClient.DEFAULT_REGISTRY_URL` (e.g., `https://geminicli.com/teams.json`).
|
||||
|
||||
## Verification
|
||||
- Unit tests in `packages/core/src/agents/teamRegistryClient.test.ts`.
|
||||
- Verify fetching from a mocked JSON endpoint.
|
||||
@@ -0,0 +1,24 @@
|
||||
# TASK-10: Team Installation and Scaffolding Service
|
||||
|
||||
## Objective
|
||||
Enhance the `TeamScaffolder` service to handle installing teams from a `RegistryTeam` definition, including support for definitions with only metadata or full Git-based teams.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Scaffolder Enhancements (`packages/core/src/agents/teamScaffolder.ts`)
|
||||
- [ ] Implement `installRegistryTeam(team: RegistryTeam)`:
|
||||
- [ ] Validates the team definition.
|
||||
- [ ] If `sourceUrl` is provided (e.g., a GitHub link), handle cloning/downloading:
|
||||
- Reuse logic from `ExtensionManager` (`cloneFromGit`, `downloadFromGitHubRelease`).
|
||||
- Copy the contents to `.gemini/teams/<team-name>/`.
|
||||
- [ ] If only prompt/metadata are provided in the `RegistryTeam` JSON:
|
||||
- Scaffold the directory structure.
|
||||
- Generate `TEAM.md`.
|
||||
- Generate corresponding `.md` agent files in the `agents/` folder (especially for external kind agents).
|
||||
|
||||
### 2. Registry Update
|
||||
- [ ] Ensure the `TeamRegistry` is notified to reload discovered teams after a successful installation.
|
||||
|
||||
## Verification
|
||||
- Unit tests for `installRegistryTeam` with both "Definition only" and "Full Git-based" team mocks.
|
||||
- Verify that a successfully installed team appears in the discovered teams list.
|
||||
@@ -0,0 +1,24 @@
|
||||
# TASK-11: UI - Browse Agent Teams View
|
||||
|
||||
## Objective
|
||||
Implement a fully functional "Browse Agent Teams" component in the CLI to allow users to search, browse, and install teams from a registry.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. New UI Components (`packages/cli/src/ui/components/views/`)
|
||||
- [ ] Create `TeamRegistryView.tsx`:
|
||||
- Uses `useTeamRegistry` hook (mirroring `useExtensionRegistry`).
|
||||
- Displays a `SearchableList` of `RegistryTeam`s.
|
||||
- Visuals: Use the color-coded provider tags (TASK-07).
|
||||
- [ ] Create `TeamDetailsView.tsx`:
|
||||
- Shows team objective, author, and roster of agents.
|
||||
- Action: "Install Team".
|
||||
|
||||
### 2. Integration in TeamSelectionDialog (`packages/cli/src/ui/components/TeamSelectionDialog.tsx`)
|
||||
- [ ] Rename "Marketplace" to "Browse Agent Teams".
|
||||
- [ ] Replace the marketplace placeholder with the new `TeamRegistryView`.
|
||||
- [ ] Support back navigation from `TeamRegistryView` to the initial selection screen.
|
||||
|
||||
## Verification
|
||||
- Manual verification of searching for a team in the registry.
|
||||
- Verify "Install Team" transitions correctly to the main selection screen with the new team selected.
|
||||
@@ -0,0 +1,26 @@
|
||||
# TASK-12: Sample Teams Registry and End-to-End Integration
|
||||
|
||||
## Objective
|
||||
Finalize the "Browse Agent Teams" experience by providing a sample registry and verifying the full browsing and installation lifecycle.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Sample Registry (`packages/core/assets/teams-registry.json`)
|
||||
- [ ] Create a sample `teams.json` registry containing 2-3 experimental teams.
|
||||
- Team A (Local Only): "System Admin Team" (Shell + FS Expert agents).
|
||||
- Team B (Multi-LLM): "Creative Polyglot" (Gemini + Claude Code).
|
||||
- Team C (Remote): "Open Source Contributor" (Git source pointing to a repo).
|
||||
|
||||
### 2. Integration Testing
|
||||
- [ ] Add an integration test in `integration-tests/agent-teams-browse.test.ts`:
|
||||
- Mocks the registry endpoint.
|
||||
- Verifies "Browse Agent Teams" lists the teams.
|
||||
- Verifies "Install Team" correctly scaffolds the local directory and notifies the registry.
|
||||
|
||||
### 3. Polish
|
||||
- [ ] Ensure all "Marketplace" strings are replaced with "Browse Agent Teams".
|
||||
- [ ] Ensure the `/team` slash command also supports browsing/installing teams.
|
||||
|
||||
## Verification
|
||||
- Run `npm test -w @google/gemini-cli-core` for the new backend services.
|
||||
- Run `npm run test:e2e` for the full browser and installation flow.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Agent Teams: Research & Implementation Specification
|
||||
|
||||
This document serves as a detailed design specification for the "Agent Teams"
|
||||
feature. It is intended to be used as a blueprint for implementation by an
|
||||
automated agent.
|
||||
|
||||
## Core Concept
|
||||
|
||||
Agent Teams are specialized collections of sub-agents orchestrated by the
|
||||
top-level Gemini CLI agent. Each team provides a set of tools (via its agents)
|
||||
and a set of instructions (via `TEAM.md`) that guide the top-level agent on how
|
||||
to delegate work effectively.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
### 1. Storage & Discovery
|
||||
|
||||
Teams are stored in `.gemini/teams/`. Each team is a directory containing:
|
||||
|
||||
- `TEAM.md`: A Markdown file with YAML frontmatter for metadata (`name`,
|
||||
`display_name`, `description`) and a body containing orchestration
|
||||
instructions.
|
||||
- `agents/`: A sub-directory containing standard agent definitions (`.md` files)
|
||||
that comprise the team.
|
||||
|
||||
### 2. Core Components
|
||||
|
||||
- **`TeamDefinition`**: The internal representation of a team, including its
|
||||
instructions and associated `AgentDefinition`s.
|
||||
- **`TeamLoader`**: Responsible for scanning the filesystem, parsing `TEAM.md`,
|
||||
and loading team agents.
|
||||
- **`TeamRegistry`**: Central manager for all discovered teams and the active
|
||||
team session state.
|
||||
- **`SubagentTool` Prioritization**: Logic in the `ToolRegistry` or `Config` to
|
||||
surface team-specific agents as preferred tools.
|
||||
|
||||
### 3. Orchestration
|
||||
|
||||
The top-level Gemini CLI agent is modified to be "team-aware":
|
||||
|
||||
- Its **System Prompt** is dynamically updated to include the active team's
|
||||
instructions.
|
||||
- It is instructed to **prioritize delegation** to team agents for tasks
|
||||
matching the team's purpose.
|
||||
|
||||
### 4. User Experience
|
||||
|
||||
- **Startup Selection**: Users are prompted to select a team on launch if
|
||||
multiple teams are available and no default is set.
|
||||
- **Active Team Indicator**: A clear visual status in the CLI showing the
|
||||
currently active team.
|
||||
|
||||
## Execution Order
|
||||
|
||||
The implementation should follow this strict sequence to ensure a solid
|
||||
foundation before moving to UI:
|
||||
|
||||
1. **TASK-01**: Core Types, Loader, and Registry.
|
||||
2. **TASK-02**: Integration into `Config` and lifecycle management.
|
||||
3. **TASK-03**: Prompt Engineering and Tool Prioritization logic.
|
||||
4. **TASK-04**: CLI Startup UX (Selection Dialog).
|
||||
5. **TASK-05**: CLI Main UI (Status Indicator) and Sample Team creation.
|
||||
+1671
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user