mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -07:00
feat(cli,core): implement interactive Team Creator Wizard and persistence
- Add interactive 5-step TeamCreatorWizard with agent selection and objective editor - Implement /team slash command for team selection and creation - Add colorized Nerd Font logos and scroll support for agent roster selection - Refactor teamScaffolder to generate standalone .md files for all agents - Implement persistent activeTeam setting in workspace configuration - Add ActiveTeamChanged event to force immediate system instruction refresh - Support external editor (Ctrl+X) for team objective instructions - Enhance keyboard navigation with Tab/Shift+Tab field switching in wizard - Fix team selection logic to correctly pivot to creation wizard - Update various UI components and tests for team state management
This commit is contained in:
@@ -121,10 +121,15 @@ export class TeamRegistry {
|
||||
|
||||
/**
|
||||
* Sets the current active team.
|
||||
* @param name The slug (name) of the team to activate.
|
||||
* @throws Error if the team is not found.
|
||||
* @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.
|
||||
*/
|
||||
setActiveTeam(name: string): void {
|
||||
setActiveTeam(name: string | undefined): void {
|
||||
if (name === undefined) {
|
||||
this.activeTeamName = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.teams.has(name)) {
|
||||
this.activeTeamName = name;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @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 { scaffoldTeam, type ScaffoldTeamOptions } from './teamScaffolder.js';
|
||||
|
||||
describe('teamScaffolder', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-team-scaffolder-test-'),
|
||||
);
|
||||
});
|
||||
|
||||
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('provider: claude-code');
|
||||
expect(content).toContain('Do some testing.');
|
||||
});
|
||||
|
||||
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-agent.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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -736,6 +736,7 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
activeTeam?: string;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
@@ -894,6 +895,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private mcpInitializationPromise: Promise<void> | null = null;
|
||||
readonly storage: Storage;
|
||||
private readonly _params: ConfigParameters;
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useWriteTodos: boolean;
|
||||
@@ -970,6 +972,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this._params = params;
|
||||
this._sessionId = params.sessionId;
|
||||
this.clientName = params.clientName;
|
||||
this.clientVersion = params.clientVersion ?? 'unknown';
|
||||
@@ -1433,6 +1436,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
await this.agentRegistry.initialize();
|
||||
await this.teamRegistry.initialize();
|
||||
|
||||
if (this._params.activeTeam) {
|
||||
try {
|
||||
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);
|
||||
|
||||
this._toolRegistry = await this.createToolRegistry();
|
||||
@@ -2037,13 +2048,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
setActiveTeam(name: string | undefined): void {
|
||||
if (name) {
|
||||
this.teamRegistry.setActiveTeam(name);
|
||||
} else {
|
||||
// Logic for clearing active team could be added here if needed,
|
||||
// but for now TeamRegistry.setActiveTeam expects a string.
|
||||
// We'll leave it as is to match TeamRegistry signature or update TeamRegistry if null is allowed.
|
||||
}
|
||||
this.teamRegistry.setActiveTeam(name);
|
||||
coreEvents.emitActiveTeamChanged(name);
|
||||
}
|
||||
|
||||
getAcknowledgedAgentsService(): AcknowledgedAgentsService {
|
||||
|
||||
@@ -120,6 +120,7 @@ export class GeminiClient {
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
coreEvents.on(CoreEvent.MemoryChanged, this.handleMemoryChanged);
|
||||
coreEvents.on(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged);
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
@@ -134,6 +135,10 @@ export class GeminiClient {
|
||||
this.updateSystemInstruction();
|
||||
};
|
||||
|
||||
private handleActiveTeamChanged = () => {
|
||||
this.updateSystemInstruction();
|
||||
};
|
||||
|
||||
clearCurrentSequenceModel(): void {
|
||||
this.currentSequenceModel = null;
|
||||
}
|
||||
@@ -318,6 +323,7 @@ export class GeminiClient {
|
||||
dispose() {
|
||||
coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged);
|
||||
coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged);
|
||||
coreEvents.off(CoreEvent.ActiveTeamChanged, this.handleActiveTeamChanged);
|
||||
}
|
||||
|
||||
async resumeChat(
|
||||
|
||||
@@ -184,6 +184,7 @@ export * from './agents/types.js';
|
||||
export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
export * from './agents/teamScaffolder.js';
|
||||
|
||||
// Export browser session management
|
||||
export { resetBrowserSession } from './agents/browser/browserAgentFactory.js';
|
||||
|
||||
@@ -193,6 +193,7 @@ export enum CoreEvent {
|
||||
EditorSelected = 'editor-selected',
|
||||
SlashCommandConflicts = 'slash-command-conflicts',
|
||||
QuotaChanged = 'quota-changed',
|
||||
ActiveTeamChanged = 'active-team-changed',
|
||||
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
|
||||
TelemetryTokenStorageType = 'telemetry-token-storage-type',
|
||||
}
|
||||
@@ -226,6 +227,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.RequestEditorSelection]: never[];
|
||||
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
|
||||
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
|
||||
[CoreEvent.ActiveTeamChanged]: [string | undefined];
|
||||
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
|
||||
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
|
||||
}
|
||||
@@ -403,6 +405,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.QuotaChanged, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that the active team has changed.
|
||||
*/
|
||||
emitActiveTeamChanged(teamName: string | undefined): void {
|
||||
this.emit(CoreEvent.ActiveTeamChanged, teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||
* subscribes.
|
||||
|
||||
Reference in New Issue
Block a user