Compare commits

...

15 Commits

Author SHA1 Message Date
Taylor Mullen f87c355bf3 wip 2026-04-20 18:59:32 -07:00
Taylor Mullen 19b9e3e8e4 feat(agents): implement team registry client and installation service
- Add RegistryTeam interface and TeamRegistryClient for remote/local discovery.
- Enhance TeamScaffolder with installRegistryTeam supporting Git and metadata-only teams.
- Refactor GitHub/Git utilities from cli to core for shared usage.
- Add comprehensive unit tests for new services.

TASK-09, TASK-10
2026-04-06 16:20:00 -07:00
Taylor Mullen 59153cc1c5 fix(cli): refine team creation UI with consistent provider markers and non-persistent selection
- Removed persistent team selection from settings and config
- Added ProviderTag component for brand-consistent provider labels (Claude Code, Gemma, etc.)
- Refined agent roster UI in TeamCreatorWizard (removed extra borders, fixed overflow, updated footer)
- Updated Step 3 and Step 4 to show provider markers next to agent names
- Simplified UI by removing redundant collective provider summary lines
- Standardized visual language to match primary status indicators
2026-04-06 12:03:36 -07:00
Taylor Mullen cc99774e1a fix(core): improve team isolation, policy, and model config
- Implement dynamic agent registration/unregistration in TeamRegistry
- Only promote active team agents to the global AgentRegistry
- Automatically ALLOW team agents in policy engine (isTeamAgent metadata)
- Fix ModelNotFoundError for external agents by registering default model configs
- Update Config to refresh subagent tools when active team changes
- Resolve bug where all discovered team agents were available to orchestrator
2026-04-03 17:10:51 -07:00
Taylor Mullen a40af4e03e feat(cli,core): implement interactive Team Creator Wizard and persistence
- Add interactive 5-step TeamCreatorWizard with agent selection and objective editor
- Implement /team slash command for team selection and creation
- Add colorized Nerd Font logos and scroll support for agent roster selection
- Refactor teamScaffolder to generate standalone .md files for all agents
- Implement persistent activeTeam setting in workspace configuration
- Add ActiveTeamChanged event to force immediate system instruction refresh
- Support external editor (Ctrl+X) for team objective instructions
- Enhance keyboard navigation with Tab/Shift+Tab field switching in wizard
- Fix team selection logic to correctly pivot to creation wizard
- Update various UI components and tests for team state management
2026-04-03 16:54:49 -07:00
Taylor Mullen 1984c9d35b feat(cli,core): expand provider ecosystem with Antigravity and Gemma
- Add Antigravity (Light Blue) and Gemma (Gemini Blue) providers
- Update Gemini CLI brand color to Purple
- Implement personality overlays for Antigravity and Gemma in core
- Ensure brand-accurate labels and colors across Marketplace and Status Bar
- Add unit tests for new provider personality overlays
2026-04-02 09:53:11 -07:00
Taylor Mullen 79cc1eb4f4 feat(core,cli): implement external agent polyfills and finalize teams
- Implement External Agent kind with personality overlays (Claude Code, Codex)
- Enhance AgentLoader and Registry for external agent discovery and registration
- Fix critical startup race condition in Config registry initialization
- Add TeamSelectionDialog and ActiveTeamIndicator to CLI UI
- Include comprehensive integration and unit tests for agent teams
- Refactor SubagentToolWrapper for type-safe external agent invocation
2026-04-01 17:55:00 -07:00
Taylor Mullen 31584285a1 chore(core): resolve conflict between efficiency and team delegation
- Add EXCEPTION to 'Assertive Action' clause for Active Agent Teams
- Ensure team mandates override context efficiency for specialized tasks
- Applied to both modern and legacy prompt snippets
2026-04-01 16:42:21 -07:00
Taylor Mullen 3b2708bbc8 chore(core): finalize hardened agent team orchestration logic
- Move active team instructions to top of system prompt
- Inject explicit Team Orchestration Mandate into Primary Workflows
- Make team-based sub-agent tool descriptions MANDATORY
- Use extremely aggressive delegation policies in sample teams
- Fix compilation and lint errors in snippets
2026-04-01 16:38:51 -07:00
Taylor Mullen 25c1ed21e6 chore(core): strengthen agent team orchestration prompts
- Add 'Strict Delegation Policy' to sample Coding and Writing teams
- Make active team prompt injection more authoritative in snippets.ts
- Explicitly forbid main agent from performing team roles itself
2026-04-01 16:20:37 -07:00
Taylor Mullen 3ff97102a2 feat(cli): add active team indicator and sample teams; fix startup crash
- Add ActiveTeamIndicator to StatusRow.tsx
- Create 'Coding Team' and 'Creative Writing Team' sample definitions
- Move registry instantiations to Config constructor to prevent startup race conditions
- Resolve syntax corruptions in Config.ts and StatusRow.tsx
- Ensure core tests and CLI build are clean
2026-04-01 16:08:25 -07:00
Taylor Mullen 1c5646b68d feat(cli): implement startup team selection dialog and UI integration
- Add TeamSelectionDialog component for team discovery UX
- Intercept startup flow in AppContainer to show team selection if needed
- Update DialogManager to orchestrate team selection UI
- Add handleTeamSelect and isTeamSelectionActive to UI contexts
- Fix pre-existing StyledLine compilation error in TableRenderer
- Refactor promptProvider-teams.test.ts to resolve type errors
- Add unit tests for TeamSelectionDialog
- Fix ESLint warnings for exhaustive-deps in AppContainer
- Resolve syntax corruptions in AppContainer.tsx and DialogManager.tsx
2026-04-01 15:58:16 -07:00
Taylor Mullen 5aba28c8be feat(core): enable team-aware orchestration in top-level agent
- Update SystemPromptOptions to include activeTeam
- Implement renderActiveTeam snippet for team instructions injection
- Update PromptProvider to pass activeTeam from config to system prompt
- Enhance SubagentTool to support description overrides
- Update Config to prioritize and label team-based subagent tools
- Ensure synchronized team reload in TeamRegistry and Config
- Add unit tests for team-aware prompt generation
2026-04-01 15:42:23 -07:00
Taylor Mullen cce060646c feat(core): integrate TeamRegistry into Config and support multi-level discovery
- Update Config to manage and initialize TeamRegistry
- Support team discovery from both global (~/.gemini/teams) and project-level directories
- Implement reload logic for teams during agent registry refresh
- Add getActiveTeam and setActiveTeam accessors to Config
- Add integration tests for Config and TeamRegistry discovery
2026-04-01 15:20:24 -07:00
Taylor Mullen ef80628b86 feat(core): implement core infrastructure for Agent Teams
- Add TeamDefinition interface to define agent team structure
- Implement TeamLoader for discovering and parsing TEAM.md files
- Implement TeamRegistry for team session management and agent registration
- Integrate TeamRegistry into Config and Storage services
- Add unit tests for team loader and registry
2026-04-01 15:07:50 -07:00
77 changed files with 7370 additions and 1372 deletions
+3 -2
View File
@@ -6,6 +6,7 @@
"topicUpdateNarration": true
},
"general": {
"devtools": true
"devtools": true,
"activeTeam": "test-team"
}
}
}
+19 -32
View File
@@ -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",
+11
View File
@@ -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,
+21 -499
View File
@@ -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,
);
});
});
+20 -423
View File
@@ -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';
+10
View File
@@ -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,
+9 -1
View File
@@ -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(),
};
+41
View File
@@ -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',
}),
},
],
};
+2
View File
@@ -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');
});
});
+43 -2
View File
@@ -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>;
+5 -3
View File
@@ -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,
};
}
+10 -10
View File
@@ -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);
});
+3
View File
@@ -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",
+173 -81
View File
@@ -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();
}
}
+84 -2
View File
@@ -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)}`,
);
}
}
}
}
+2 -1
View File
@@ -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,
+183
View File
@@ -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);
});
});
+189
View File
@@ -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);
});
});
+183
View File
@@ -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();
});
});
});
+192
View File
@@ -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)}`,
);
}
}
+78 -1
View File
@@ -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');
});
});
+77 -26
View File
@@ -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 };
+8
View File
@@ -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');
}
+6
View File
@@ -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(
+5
View File
@@ -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,
),
+10 -2
View File
@@ -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.
+21 -2
View File
@@ -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 directlysimple 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 directlysimple 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.
*
+9
View File
@@ -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.
+223
View File
@@ -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();
});
});
});
+423
View File
@@ -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',
}),
}),
);
});
});
});
+41
View File
@@ -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;
}
+58
View File
@@ -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.
+40
View File
@@ -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.
+40
View File
@@ -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.
+39
View File
@@ -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).
+44
View File
@@ -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.
+48
View File
@@ -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`.
+49
View File
@@ -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.
+47
View File
@@ -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`.
+44
View File
@@ -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.
+24
View File
@@ -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.
+24
View File
@@ -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.
+26
View File
@@ -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.
+62
View File
@@ -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
View File
File diff suppressed because one or more lines are too long