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),
};
+116
View File
@@ -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}`,
};
}
}
+18 -1
View File
@@ -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;
}
+2
View File
@@ -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}`);
}
}
}
+53
View File
@@ -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.
+3 -1
View File
@@ -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`).