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