feat(workspaces): implement wsr command group and hub configuration

This commit is contained in:
mkorwel
2026-03-19 08:30:24 -07:00
parent 0c04e7c6ef
commit 4de0f916d7
17 changed files with 812 additions and 2 deletions
+28
View File
@@ -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
},
};
@@ -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<CreateArgs>,
): Promise<void> {
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<object, CreateArgs> = {
command: 'create <name>',
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();
},
};
@@ -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<DeleteArgs>,
): Promise<void> {
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<object, DeleteArgs> = {
command: 'delete <id>',
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();
},
};
@@ -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<ListArgs>,
): Promise<void> {
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<object, ListArgs> = {
command: 'list',
describe: 'List all remote workspaces',
handler: async (argv) => {
await listWorkspaces(argv);
await exitCli();
},
};
+6
View File
@@ -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', () => {
+3
View File
@@ -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,
+44
View File
@@ -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.',
+12
View File
@@ -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,
+89
View File
@@ -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<void> {
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 <name>',
),
);
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 <id>'),
);
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 <command> [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);
}
}
@@ -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 ===
@@ -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<void | MessageActionReturn> => {
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<MessageActionReturn> => {
const name = args.trim();
if (!name) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace name is required. Usage: /workspace create <name>',
};
}
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<MessageActionReturn> => {
const id = args.trim();
if (!id) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace ID is required. Usage: /workspace delete <id>',
};
}
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),
};