mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-22 09:17:33 -07:00
feat(workspaces): implement wsr command group and hub configuration
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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<MessageActionReturn> {
|
||||
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<MessageActionReturn> {
|
||||
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<MessageActionReturn> {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,15 @@ export class MCPServerConfig {
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface WorkspaceHubConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
hubs: Record<string, WorkspaceHubConfig>;
|
||||
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<string, MCPServerConfig>;
|
||||
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<string, MCPServerConfig> | 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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<WorkspaceHubInfo[]> {
|
||||
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<WorkspaceHubInfo> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <name>`: Call the Hub API to provision a new
|
||||
workspace.
|
||||
- [x] Implement `wsr delete <id>`: 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.
|
||||
@@ -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`).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user