diff --git a/packages/cli/src/commands/workspace.ts b/packages/cli/src/commands/workspace.ts new file mode 100644 index 0000000000..8a25abf44f --- /dev/null +++ b/packages/cli/src/commands/workspace.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini wsr' command +import type { CommandModule, Argv } from 'yargs'; +import { listCommand } from './workspace/list.js'; +import { createCommand } from './workspace/create.js'; +import { deleteCommand } from './workspace/delete.js'; +import { defer } from '../deferred.js'; + +export const remoteWorkspaceCommand: CommandModule = { + command: 'wsr', + describe: 'Manage remote workspaces', + builder: (yargs: Argv) => + yargs + .command(defer(listCommand, 'wsr')) + .command(defer(createCommand, 'wsr')) + .command(defer(deleteCommand, 'wsr')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + + handler: () => { + // yargs will automatically show help if no subcommand is provided + }, +}; diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts new file mode 100644 index 0000000000..89b31169c2 --- /dev/null +++ b/packages/cli/src/commands/workspace/create.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, ArgumentsCamelCase } from 'yargs'; +import { + createWorkspace as performCreateWorkspace, + type Config, +} from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +interface CreateArgs { + config?: Config; + name: string; + machineType?: string; +} + +export async function createWorkspace( + args: ArgumentsCamelCase, +): Promise { + if (!args.config) { + // eslint-disable-next-line no-console + console.error(chalk.red('Internal error: Config not loaded.')); + return; + } + + // eslint-disable-next-line no-console + console.log( + chalk.yellow(`Requesting creation of workspace "${args.name}"...`), + ); + + const result = await performCreateWorkspace( + args.config, + args.name, + args.machineType, + ); + + if (result.type === 'message') { + if (result.messageType === 'error') { + // eslint-disable-next-line no-console + console.error(chalk.red(result.content)); + } else { + // eslint-disable-next-line no-console + console.log(chalk.green(result.content)); + } + } +} + +export const createCommand: CommandModule = { + command: 'create ', + describe: 'Create a new remote workspace', + builder: (yargs) => yargs + .positional('name', { + type: 'string', + describe: 'Name of the workspace', + demandOption: true, + }) + .option('machine-type', { + type: 'string', + describe: 'GCE machine type', + }), + handler: async (argv) => { + await createWorkspace(argv); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/workspace/delete.ts b/packages/cli/src/commands/workspace/delete.ts new file mode 100644 index 0000000000..1e1dc0faa6 --- /dev/null +++ b/packages/cli/src/commands/workspace/delete.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, ArgumentsCamelCase } from 'yargs'; +import { + deleteWorkspace as performDeleteWorkspace, + type Config, +} from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +interface DeleteArgs { + config?: Config; + id: string; +} + +export async function deleteWorkspace( + args: ArgumentsCamelCase, +): Promise { + if (!args.config) { + // eslint-disable-next-line no-console + console.error(chalk.red('Internal error: Config not loaded.')); + return; + } + + // eslint-disable-next-line no-console + console.log(chalk.yellow(`Deleting workspace "${args.id}"...`)); + + const result = await performDeleteWorkspace(args.config, args.id); + + if (result.type === 'message') { + if (result.messageType === 'error') { + // eslint-disable-next-line no-console + console.error(chalk.red(result.content)); + } else { + // eslint-disable-next-line no-console + console.log(chalk.green(result.content)); + } + } +} + +export const deleteCommand: CommandModule = { + command: 'delete ', + describe: 'Delete a remote workspace', + builder: (yargs) => yargs.positional('id', { + type: 'string', + describe: 'ID of the workspace to delete', + demandOption: true, + }), + handler: async (argv) => { + await deleteWorkspace(argv); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/workspace/list.ts b/packages/cli/src/commands/workspace/list.ts new file mode 100644 index 0000000000..46f5098b16 --- /dev/null +++ b/packages/cli/src/commands/workspace/list.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule, ArgumentsCamelCase } from 'yargs'; +import { + listWorkspaces as performListWorkspaces, + type Config, +} from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +interface ListArgs { + config?: Config; +} + +export async function listWorkspaces( + args: ArgumentsCamelCase, +): Promise { + if (!args.config) { + // eslint-disable-next-line no-console + console.error(chalk.red('Internal error: Config not loaded.')); + return; + } + + const result = await performListWorkspaces(args.config); + + if (result.type === 'message') { + if (result.messageType === 'error') { + // eslint-disable-next-line no-console + console.error(chalk.red(result.content)); + } else { + // eslint-disable-next-line no-console + console.log(result.content); + } + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all remote workspaces', + handler: async (argv) => { + await listWorkspaces(argv); + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index a94d1f0a28..edbdeef526 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -671,6 +671,12 @@ describe('parseArguments', () => { const argv = await parseArguments(settings); expect(argv.isCommand).toBe(true); }); + + it('should set isCommand to true for workspace command', async () => { + process.argv = ['node', 'script.js', 'workspace', 'list']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.isCommand).toBe(true); + }); }); describe('loadCliConfig', () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 80c1e19443..de087a77f2 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; import { mcpCommand } from '../commands/mcp.js'; +import { remoteWorkspaceCommand as workspaceCommand } from '../commands/workspace.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; @@ -131,6 +132,7 @@ export async function parseArguments( description: 'Run in debug mode (open debug console with F12)', default: false, }) + .command(workspaceCommand) .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) => yargsInstance .positional('query', { @@ -776,6 +778,7 @@ export async function loadCliConfig( toolCallCommand: settings.tools?.callCommand, mcpServerCommand, mcpServers, + workspaces: settings.workspaces, mcpEnablementCallbacks, mcpEnabled, extensionsEnabled, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8a107c4d47..2ef42237d4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -13,6 +13,7 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, type MCPServerConfig, + type WorkspaceConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, @@ -169,6 +170,49 @@ const SETTINGS_SCHEMA = { }, }, + workspaces: { + type: 'object', + label: 'Workspaces', + category: 'Advanced', + requiresRestart: false, + default: { hubs: {} } as WorkspaceConfig, + description: 'Configuration for remote workspaces.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + properties: { + hubs: { + type: 'object', + label: 'Workspace Hubs', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Configured Workspace Hubs.', + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + additionalProperties: { + type: 'object', + properties: { + url: { + type: 'string', + label: 'Hub URL', + category: 'Advanced', + requiresRestart: false, + default: 'http://localhost:8080', + description: 'The URL of the Workspace Hub.', + }, + }, + }, + }, + defaultHub: { + type: 'string', + label: 'Default Hub', + category: 'Advanced', + requiresRestart: false, + default: undefined as string | undefined, + description: 'The name of the default Workspace Hub to use.', + }, + }, + }, + policyPaths: pathArraySetting( 'Policy Paths', 'Additional policy files or directories to load.', diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts index 1864ec2cb5..45428cd8b3 100644 --- a/packages/cli/src/deferred.ts +++ b/packages/cli/src/deferred.ts @@ -63,6 +63,18 @@ export async function runDeferredCommand(settings: MergedSettings) { process.exit(ExitCodes.FATAL_CONFIG_ERROR); } + if (commandName === 'wsr') { + // Inject settings into argv + const argvWithSettings = { + ...deferredCommand.argv, + settings, + }; + + await deferredCommand.handler(argvWithSettings); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + // Inject settings into argv const argvWithSettings = { ...deferredCommand.argv, diff --git a/packages/cli/src/remoteCli.ts b/packages/cli/src/remoteCli.ts new file mode 100644 index 0000000000..6cbba13a0d --- /dev/null +++ b/packages/cli/src/remoteCli.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceHubClient } from '@google/gemini-cli-core'; +import chalk from 'chalk'; + +export async function runRemoteCommand(args: string[]): Promise { + const command = args[0]; + const hubUrl = + process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; + const client = new WorkspaceHubClient(hubUrl); + + try { + if (command === 'list' || command === 'ls') { + const workspaces = await client.listWorkspaces(); + if (workspaces.length === 0) { + // eslint-disable-next-line no-console + console.log('No active workspaces found.'); + return; + } + // eslint-disable-next-line no-console + console.log(chalk.bold('Active Workspaces:')); + // eslint-disable-next-line no-console + console.log( + '------------------------------------------------------------', + ); + for (const ws of workspaces) { + const statusColor = ws.status === 'READY' ? chalk.green : chalk.yellow; + // eslint-disable-next-line no-console + console.log( + `${chalk.cyan(ws.name.padEnd(20))} | ${statusColor(ws.status.padEnd(12))} | ${ws.id}`, + ); + } + // eslint-disable-next-line no-console + console.log( + '------------------------------------------------------------', + ); + } else if (command === 'create') { + const name = args[1]; + if (!name) { + // eslint-disable-next-line no-console + console.error( + chalk.red( + 'Error: Workspace name is required. Usage: wsr create ', + ), + ); + process.exit(1); + } + // eslint-disable-next-line no-console + console.log( + chalk.yellow(`Requesting creation of workspace "${name}"...`), + ); + const ws = await client.createWorkspace(name); + // eslint-disable-next-line no-console + console.log(chalk.green(`✅ Workspace created successfully!`)); + // eslint-disable-next-line no-console + console.log(`${chalk.bold('ID:')} ${ws.id}`); + // eslint-disable-next-line no-console + console.log(`${chalk.bold('Name:')} ${ws.name}`); + } else if (command === 'delete' || command === 'rm') { + const id = args[1]; + if (!id) { + // eslint-disable-next-line no-console + console.error( + chalk.red('Error: Workspace ID is required. Usage: wsr delete '), + ); + process.exit(1); + } + // eslint-disable-next-line no-console + console.log(chalk.yellow(`Deleting workspace "${id}"...`)); + await client.deleteWorkspace(id); + // eslint-disable-next-line no-console + console.log(chalk.green(`✅ Workspace deleted successfully.`)); + } else { + // eslint-disable-next-line no-console + console.log('Usage: wsr [args]'); + // eslint-disable-next-line no-console + console.log('Commands: list, create, delete'); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // eslint-disable-next-line no-console + console.error(chalk.red('Remote command failed:'), message); + process.exit(1); + } +} diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..f2a9d86940 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -58,6 +58,7 @@ import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; +import { workspaceSlashCommand } from '../ui/commands/workspaceCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; import { upgradeCommand } from '../ui/commands/upgradeCommand.js'; @@ -223,6 +224,7 @@ export class BuiltinCommandLoader implements ICommandLoader { settingsCommand, shellsCommand, vimCommand, + workspaceSlashCommand, setupGithubCommand, terminalSetupCommand, ...(this.config?.getContentGeneratorConfig()?.authType === diff --git a/packages/cli/src/ui/commands/workspaceCommand.ts b/packages/cli/src/ui/commands/workspaceCommand.ts new file mode 100644 index 0000000000..9f9f2d7284 --- /dev/null +++ b/packages/cli/src/ui/commands/workspaceCommand.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, CommandContext } from './types.js'; +import { CommandKind } from './types.js'; +import type { MessageActionReturn } from '@google/gemini-cli-core'; +import { WorkspaceHubClient } from '@google/gemini-cli-core'; + +const listAction = async ( + + _context: CommandContext, +): Promise => { + const hubUrl = + process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; + const client = new WorkspaceHubClient(hubUrl); + + try { + const workspaces = await client.listWorkspaces(); + + if (workspaces.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No active workspaces found.', + }; + } + + let content = 'Active Workspaces:\n'; + content += '------------------------------------------------------------\n'; + for (const ws of workspaces) { + content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`; + } + content += '------------------------------------------------------------'; + + return { + type: 'message', + messageType: 'info', + content, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message; + return { + type: 'message', + messageType: 'error', + content: `Failed to list workspaces: ${message}`, + }; + } +}; + +const listCommand: SlashCommand = { + name: 'list', + altNames: ['ls'], + description: 'List remote workspaces', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context) => listAction(context), +}; + +const createCommand: SlashCommand = { + name: 'create', + description: 'Create a new remote workspace', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const name = args.trim(); + if (!name) { + return { + type: 'message', + messageType: 'error', + content: 'Workspace name is required. Usage: /workspace create ', + }; + } + + const hubUrl = + process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; + const client = new WorkspaceHubClient(hubUrl); + + try { + context.ui.addItem({ + type: 'info', + text: `Requesting creation of workspace "${name}"...`, + }); + const ws = await client.createWorkspace(name); + return { + type: 'message', + messageType: 'info', + content: `✅ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message; + return { + type: 'message', + messageType: 'error', + content: `Failed to create workspace: ${message}`, + }; + } + }, +}; + +const deleteCommand: SlashCommand = { + name: 'delete', + altNames: ['rm'], + description: 'Delete a remote workspace', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const id = args.trim(); + if (!id) { + return { + type: 'message', + messageType: 'error', + content: 'Workspace ID is required. Usage: /workspace delete ', + }; + } + + const hubUrl = + process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; + const client = new WorkspaceHubClient(hubUrl); + + try { + context.ui.addItem({ + type: 'info', + text: `Deleting workspace "${id}"...`, + }); + await client.deleteWorkspace(id); + return { + type: 'message', + messageType: 'info', + content: `✅ Workspace ${id} deleted successfully.`, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message; + return { + type: 'message', + messageType: 'error', + content: `Failed to delete workspace: ${message}`, + }; + } + }, +}; + +export const workspaceSlashCommand: SlashCommand = { + name: 'workspace', + description: 'Manage remote workspaces', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [listCommand, createCommand, deleteCommand], + action: async (context: CommandContext) => listAction(context), +}; diff --git a/packages/core/src/commands/workspace.ts b/packages/core/src/commands/workspace.ts new file mode 100644 index 0000000000..9df07e88e6 --- /dev/null +++ b/packages/core/src/commands/workspace.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { WorkspaceHubClient } from '../services/workspaceHubClient.js'; +import type { MessageActionReturn } from './types.js'; + +function getHubUrl(config: Config): string { + if (process.env['GEMINI_WORKSPACE_HUB_URL']) { + return process.env['GEMINI_WORKSPACE_HUB_URL']; + } + + const workspaces = config.getWorkspaces(); + if (workspaces) { + const hubName = workspaces.defaultHub; + if (hubName && workspaces.hubs[hubName]) { + return workspaces.hubs[hubName].url; + } + } + + return 'http://localhost:8080'; +} + +export async function listWorkspaces( + config: Config, +): Promise { + const hubUrl = getHubUrl(config); + const client = new WorkspaceHubClient(hubUrl); + + try { + const workspaces = await client.listWorkspaces(); + + if (workspaces.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No active workspaces found.', + }; + } + + let content = 'Active Workspaces:\n'; + content += '------------------------------------------------------------\n'; + for (const ws of workspaces) { + content += `${ws.name.padEnd(20)} | ${ws.status.padEnd(12)} | ${ws.id}\n`; + } + content += '------------------------------------------------------------'; + + return { + type: 'message', + messageType: 'info', + content, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message || String(error); + return { + type: 'message', + messageType: 'error', + content: `Failed to list workspaces: ${message}`, + }; + } +} + +export async function createWorkspace( + config: Config, + name: string, + machineType?: string, +): Promise { + const hubUrl = getHubUrl(config); + const client = new WorkspaceHubClient(hubUrl); + + try { + const ws = await client.createWorkspace(name, machineType); + return { + type: 'message', + messageType: 'info', + content: `✓ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message || String(error); + return { + type: 'message', + messageType: 'error', + content: `Failed to create workspace: ${message}`, + }; + } +} + +export async function deleteWorkspace( + config: Config, + id: string, +): Promise { + const hubUrl = getHubUrl(config); + const client = new WorkspaceHubClient(hubUrl); + + try { + await client.deleteWorkspace(id); + return { + type: 'message', + messageType: 'info', + content: `✓ Workspace ${id} deleted successfully.`, + }; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = (error as Error).message || String(error); + return { + type: 'message', + messageType: 'error', + content: `Failed to delete workspace: ${message}`, + }; + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index aa3e9aa5b6..7709310946 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -457,6 +457,15 @@ export class MCPServerConfig { ) {} } +export interface WorkspaceHubConfig { + url: string; +} + +export interface WorkspaceConfig { + hubs: Record; + defaultHub?: string; +} + export enum AuthProviderType { DYNAMIC_DISCOVERY = 'dynamic_discovery', GOOGLE_CREDENTIALS = 'google_credentials', @@ -534,6 +543,7 @@ export interface ConfigParameters { toolCallCommand?: string; mcpServerCommand?: string; mcpServers?: Record; + workspaces?: WorkspaceConfig; mcpEnablementCallbacks?: McpEnablementCallbacks; userMemory?: string | HierarchicalMemory; geminiMdFileCount?: number; @@ -693,7 +703,9 @@ export class Config implements McpContext, AgentLoopContext { private readonly mcpEnabled: boolean; private readonly extensionsEnabled: boolean; private mcpServers: Record | undefined; - private readonly mcpEnablementCallbacks?: McpEnablementCallbacks; + private workspaces: WorkspaceConfig | undefined; + private readonly mcpEnablementCallbacks: McpEnablementCallbacks | undefined; + private userMemory: string | HierarchicalMemory; private geminiMdFileCount: number; private geminiMdFilePaths: string[]; @@ -903,6 +915,7 @@ export class Config implements McpContext, AgentLoopContext { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.workspaces = params.workspaces; this.mcpEnablementCallbacks = params.mcpEnablementCallbacks; this.mcpEnabled = params.mcpEnabled ?? true; this.extensionsEnabled = params.extensionsEnabled ?? true; @@ -1999,6 +2012,10 @@ export class Config implements McpContext, AgentLoopContext { return this.mcpServers; } + getWorkspaces(): WorkspaceConfig | undefined { + return this.workspaces; + } + getMcpEnabled(): boolean { return this.mcpEnabled; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47412dd73c..f0af5725b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,7 @@ export * from './confirmation-bus/message-bus.js'; // Export Commands logic export * from './commands/extensions.js'; export * from './commands/restore.js'; +export * from './commands/workspace.js'; export * from './commands/init.js'; export * from './commands/memory.js'; export * from './commands/types.js'; @@ -132,6 +133,7 @@ export * from './services/trackerService.js'; export * from './services/trackerTypes.js'; export * from './services/keychainService.js'; export * from './services/keychainTypes.js'; +export * from './services/workspaceHubClient.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; diff --git a/packages/core/src/services/workspaceHubClient.ts b/packages/core/src/services/workspaceHubClient.ts new file mode 100644 index 0000000000..73dc815ca4 --- /dev/null +++ b/packages/core/src/services/workspaceHubClient.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchWithTimeout } from '../utils/fetch.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface WorkspaceHubInfo { + id: string; + name: string; + instance_name: string; + status: string; + machine_type: string; + zone: string; + created_at: string; + owner_id: string; +} + +export class WorkspaceHubClient { + constructor(private readonly hubUrl: string) {} + + /** + * List all workspaces for the authenticated user + */ + async listWorkspaces(): Promise { + const url = new URL('/workspaces', this.hubUrl).toString(); + debugLogger.log(`[WorkspaceHubClient] Fetching workspaces from ${url}`); + + try { + const response = await fetchWithTimeout(url, 10000, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // TODO: Add Authorization header (OAuth/IAP) + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Hub API error (${response.status}): ${errorText}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as WorkspaceHubInfo[]; + } catch (error) { + debugLogger.error( + `[WorkspaceHubClient] Failed to list workspaces:`, + error, + ); + throw error; + } + } + + /** + * Create a new workspace + */ + async createWorkspace( + name: string, + machineType?: string, + ): Promise { + const url = new URL('/workspaces', this.hubUrl).toString(); + debugLogger.log( + `[WorkspaceHubClient] Creating workspace ${name} at ${url}`, + ); + + const response = await fetchWithTimeout(url, 15000, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, machineType }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Hub API error (${response.status}): ${errorText}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as WorkspaceHubInfo; + } + + /** + * Delete a workspace + */ + async deleteWorkspace(id: string): Promise { + const url = new URL(`/workspaces/${id}`, this.hubUrl).toString(); + debugLogger.log(`[WorkspaceHubClient] Deleting workspace ${id} at ${url}`); + + const response = await fetchWithTimeout(url, 10000, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Hub API error (${response.status}): ${errorText}`); + } + } +} diff --git a/plans/milestone-2-cli-management.md b/plans/milestone-2-cli-management.md new file mode 100644 index 0000000000..3a81c96746 --- /dev/null +++ b/plans/milestone-2-cli-management.md @@ -0,0 +1,53 @@ +# Milestone 2 Sub-plan: Basic CLI Management + +## 1. Objective + +Enable developers to manage their remote workspaces directly from the local +`gemini-cli`. + +## 2. Tasks + +### Task 2.1: CLI Command Infrastructure + +Add the base `workspace` command and its sub-commands to the CLI. + +- [x] Save post-mortem of "Command Registration & UI Bypass" failure to global + memory. +- [x] Investigate why `workspace` command is shadowed by positional `query..` in + `yargs`. +- [x] Ensure `workspace` commands correctly bypass the interactive UI. +- [x] Define the `workspace` command group logic in + `packages/core/src/commands/`. +- [x] Implement `wsr list`: Fetch and display workspaces from the Hub. +- [x] Implement `wsr create `: Call the Hub API to provision a new + workspace. +- [x] Implement `wsr delete `: Call the Hub API to terminate a workspace. + +### Task 2.2: Hub Configuration & Discovery + +Allow the CLI to know where the Workspace Hub is located. + +- [ ] Add `workspaces` configuration section to `packages/core/src/config/`. +- [ ] Support multiple Hub profiles in `settings.json`. + +### Task 2.3: Basic Hub Client & Auth + +Implement the communication layer between the CLI and the Hub. + +- [ ] Create `packages/core/src/services/workspaceHubClient.ts`. +- [ ] Implement Google OAuth/IAP token injection for API requests. +- [ ] Handle API errors and provide user-friendly feedback in the CLI. + +## 3. Verification & Success Criteria + +- **List:** `gemini workspace list` shows workspaces currently tracked in + Firestore. +- **Create:** `gemini workspace create my-task` returns a success message and + the new workspace ID. +- **Delete:** `gemini workspace delete [ID]` removes the entry from the list. +- **Auth:** Commands fail with a clear message if the user is not authenticated + or the Hub is unreachable. + +## 4. Next Steps + +- Implement Task 2.1: Add the `workspace` command group to the CLI. diff --git a/plans/workspaces-implementation.md b/plans/workspaces-implementation.md index a5c4297a91..dc0739a558 100644 --- a/plans/workspaces-implementation.md +++ b/plans/workspaces-implementation.md @@ -17,9 +17,11 @@ Build the foundational container environment and the core management API. ### Milestone 2: Basic CLI Management (Phase 2) -Enable developers to manage their remote fleet from the local CLI. +Enable developers to manage their remote fleet from the local CLI. See +[Milestone 2 Sub-plan](./milestone-2-cli-management.md) for details. - [ ] Add `gemini workspace create/list/delete` commands. + - [ ] Implement Hub authentication (Google OAuth/IAP). - [ ] Add local configuration for Hub discovery (`settings.json`).