mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-19 15:56:48 -07:00
feat(cli): add /teleport command for portable session management
This commit is contained in:
@@ -52,6 +52,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { teleportCommand } from '../ui/commands/teleportCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
@@ -112,6 +113,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const chatResumeSubCommands = addDebugToChatResumeSubCommands(
|
||||
chatCommand.subCommands,
|
||||
);
|
||||
@@ -195,6 +197,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands),
|
||||
},
|
||||
statsCommand,
|
||||
teleportCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
...(this.config?.isSkillsSupportEnabled()
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { teleportCommand } from './teleportCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const mockExportSession = vi.fn();
|
||||
const mockImportSession = vi.fn();
|
||||
|
||||
vi.mock('prompts', () => ({
|
||||
default: vi.fn().mockResolvedValue({ secret: 'prompted-secret' }),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...original,
|
||||
default: {
|
||||
...original,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
TeleportService: class {
|
||||
exportSession = mockExportSession;
|
||||
importSession = mockImportSession;
|
||||
},
|
||||
getAdminErrorMessage: vi.fn().mockReturnValue('Admin Error'),
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('teleportCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExportSession.mockResolvedValue({
|
||||
packagePath: 'any.tar.gz',
|
||||
sessionId: 'current-session-id',
|
||||
filesIncluded: ['file1'],
|
||||
});
|
||||
|
||||
mockImportSession.mockResolvedValue({
|
||||
sessionId: 'imported-id',
|
||||
projectIdentifier: 'imported-project',
|
||||
});
|
||||
|
||||
mockConfig = {
|
||||
getSessionId: vi.fn().mockReturnValue('current-session-id'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
});
|
||||
|
||||
vi.stubEnv('GEMINI_TELEPORT_SECRET', '');
|
||||
mockExportSession.mockClear();
|
||||
mockImportSession.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should have the correct metadata', () => {
|
||||
expect(teleportCommand.name).toBe('teleport');
|
||||
expect(teleportCommand.subCommands).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
it('should export the current session when no args provided', async () => {
|
||||
const exportSubCommand = teleportCommand.subCommands?.find(
|
||||
(c) => c.name === 'export',
|
||||
);
|
||||
await exportSubCommand?.action?.(mockContext, '');
|
||||
|
||||
expect(mockExportSession).toHaveBeenCalledWith(
|
||||
'current-session-id',
|
||||
expect.stringContaining('gemini-session-current-'),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use environment variable for secret', async () => {
|
||||
vi.stubEnv('GEMINI_TELEPORT_SECRET', 'env-secret');
|
||||
const exportSubCommand = teleportCommand.subCommands?.find(
|
||||
(c) => c.name === 'export',
|
||||
);
|
||||
await exportSubCommand?.action?.(mockContext, '--secret');
|
||||
|
||||
expect(mockExportSession).toHaveBeenCalledWith(
|
||||
'current-session-id',
|
||||
expect.any(String),
|
||||
'env-secret',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use key file for secret', async () => {
|
||||
const exportSubCommand = teleportCommand.subCommands?.find(
|
||||
(c) => c.name === 'export',
|
||||
);
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
await exportSubCommand?.action?.(mockContext, '--key-file /path/to/key');
|
||||
|
||||
expect(mockExportSession).toHaveBeenCalledWith(
|
||||
'current-session-id',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import', () => {
|
||||
it('should import successfully if file exists', async () => {
|
||||
const importSubCommand = teleportCommand.subCommands?.find(
|
||||
(c) => c.name === 'import',
|
||||
);
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
const result = await importSubCommand?.action?.(
|
||||
mockContext,
|
||||
'my-session.tar.gz',
|
||||
);
|
||||
|
||||
expect(mockImportSession).toHaveBeenCalledWith(
|
||||
'my-session.tar.gz',
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('imported successfully'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle environment secret during import', async () => {
|
||||
vi.stubEnv('GEMINI_TELEPORT_SECRET', 'env-secret');
|
||||
const importSubCommand = teleportCommand.subCommands?.find(
|
||||
(c) => c.name === 'import',
|
||||
);
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
|
||||
await importSubCommand?.action?.(
|
||||
mockContext,
|
||||
'my-session.tar.gz --secret',
|
||||
);
|
||||
|
||||
expect(mockImportSession).toHaveBeenCalledWith(
|
||||
'my-session.tar.gz',
|
||||
'env-secret',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { TeleportService, getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
type CommandContext,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import prompts from 'prompts';
|
||||
|
||||
async function getSecret(keyFilePath?: string): Promise<string | undefined> {
|
||||
// 1. Check environment variable
|
||||
if (process.env['GEMINI_TELEPORT_SECRET']) {
|
||||
return process.env['GEMINI_TELEPORT_SECRET'];
|
||||
}
|
||||
|
||||
// 2. Check key file
|
||||
if (keyFilePath) {
|
||||
if (fs.existsSync(keyFilePath)) {
|
||||
return fs.readFileSync(keyFilePath, 'utf8').trim();
|
||||
}
|
||||
throw new Error(`Key file not found: ${keyFilePath}`);
|
||||
}
|
||||
|
||||
// 3. Interactive prompt
|
||||
const response = await prompts({
|
||||
type: 'password',
|
||||
name: 'secret',
|
||||
message: 'Enter teleport secret:',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return response.secret as string | undefined;
|
||||
}
|
||||
|
||||
export const teleportCommand: SlashCommand = {
|
||||
name: 'teleport',
|
||||
description:
|
||||
'Export or import sessions to make them portable across machines',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'export',
|
||||
description: 'Export a session to a portable tarball or blob storage',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.services.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: getAdminErrorMessage('Teleport', undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const parts = args
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p !== '');
|
||||
let sessionId = '';
|
||||
let outputPath = '';
|
||||
let useSecret = false;
|
||||
let keyFilePath: string | undefined;
|
||||
let blobUri: string | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i] === '--secret') {
|
||||
useSecret = true;
|
||||
} else if (parts[i] === '--key-file') {
|
||||
useSecret = true;
|
||||
keyFilePath = parts[i + 1];
|
||||
i++;
|
||||
} else if (parts[i] === '--blob') {
|
||||
blobUri = parts[i + 1];
|
||||
if (!blobUri || blobUri.startsWith('--')) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Please provide a blob URI after --blob flag (e.g. gs://bucket/path).',
|
||||
};
|
||||
}
|
||||
i++;
|
||||
} else if (!sessionId && !parts[i].startsWith('--')) {
|
||||
sessionId = parts[i];
|
||||
} else if (!outputPath && !parts[i].startsWith('--')) {
|
||||
outputPath = parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId || sessionId === 'current' || sessionId === '') {
|
||||
sessionId = context.services.config.getSessionId();
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
outputPath = `gemini-session-${sessionId.slice(0, 8)}.tar.gz`;
|
||||
}
|
||||
|
||||
let secret: string | undefined;
|
||||
if (useSecret) {
|
||||
try {
|
||||
secret = await getSecret(keyFilePath);
|
||||
if (!secret) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Export cancelled: secret is required.',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const teleportService = new TeleportService(context.services.config);
|
||||
try {
|
||||
const result = await teleportService.exportSession(
|
||||
sessionId,
|
||||
outputPath,
|
||||
secret,
|
||||
blobUri,
|
||||
);
|
||||
let message = `Session ${sessionId} exported successfully to ${path.resolve(outputPath)}.\nIncluded ${result.filesIncluded.length} files/directories.${secret ? ' (Encrypted)' : ''}`;
|
||||
if (blobUri) {
|
||||
message += `\nAlso uploaded to: ${blobUri}`;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'import',
|
||||
description: 'Import a session from a portable tarball or blob storage',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.services.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: getAdminErrorMessage('Teleport', undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const parts = args
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p !== '');
|
||||
let packagePathOrUri = '';
|
||||
let useSecret = false;
|
||||
let keyFilePath: string | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i] === '--secret') {
|
||||
useSecret = true;
|
||||
} else if (parts[i] === '--key-file') {
|
||||
useSecret = true;
|
||||
keyFilePath = parts[i + 1];
|
||||
i++;
|
||||
} else if (!packagePathOrUri && !parts[i].startsWith('--')) {
|
||||
packagePathOrUri = parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!packagePathOrUri) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Please provide the path or URI to the session tarball to import.',
|
||||
};
|
||||
}
|
||||
|
||||
// Only check local file if it doesn't look like a URI
|
||||
if (
|
||||
!packagePathOrUri.startsWith('gs://') &&
|
||||
!packagePathOrUri.startsWith('s3://') &&
|
||||
!fs.existsSync(packagePathOrUri)
|
||||
) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `File not found: ${packagePathOrUri}`,
|
||||
};
|
||||
}
|
||||
|
||||
let secret: string | undefined;
|
||||
if (useSecret) {
|
||||
try {
|
||||
secret = await getSecret(keyFilePath);
|
||||
if (!secret) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Import cancelled: secret is required.',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const teleportService = new TeleportService(context.services.config);
|
||||
try {
|
||||
const result = await teleportService.importSession(
|
||||
packagePathOrUri,
|
||||
secret,
|
||||
);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Session imported successfully.\nSession ID: ${result.sessionId}\nProject: ${result.projectIdentifier}\n\nYou can now resume this session using: /resume ${result.sessionId}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to import session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Use `/teleport export [session-id] [output-path] [--secret] [--key-file <path>] [--blob <uri>]` to export a session.\nUse `/teleport import <package-path-or-uri> [--secret] [--key-file <path>]` to import a session.',
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user