Add gemini extensions link command (#7241)

Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
christine betts
2025-09-02 10:15:42 -07:00
committed by GitHub
parent 997136ae25
commit 6a581a695f
15 changed files with 461 additions and 20 deletions

View File

@@ -11,6 +11,8 @@ import { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -23,6 +25,8 @@ export const extensionsCommand: CommandModule = {
.command(updateCommand)
.command(disableCommand)
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View File

@@ -0,0 +1,8 @@
# Ink Library Screen Reader Guidance
When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience.
## General Principles
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users.
Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on <Box> and <Text> to provide semantic meaning to screen readers.

View File

@@ -0,0 +1,5 @@
{
"name": "context-example",
"version": "1.0.0",
"contextFileName": "GEMINI.md"
}

View File

@@ -0,0 +1,6 @@
prompt = """
Please summarize the findings for the pattern `{{args}}`.
Search Results:
!{grep -r {{args}} .}
"""

View File

@@ -0,0 +1,4 @@
{
"name": "custom-commands",
"version": "1.0.0"
}

View File

@@ -0,0 +1,5 @@
{
"name": "excludeTools",
"version": "1.0.0",
"excludeTools": ["run_shell_command(rm -rf)"]
}

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'prompt-server',
version: '1.0.0',
});
server.registerTool(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
async () => {
const apiResponse = await fetch(
'https://jsonplaceholder.typicode.com/posts',
);
const posts = await apiResponse.json();
const response = { posts: posts.slice(0, 5) };
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
],
};
},
);
server.registerPrompt(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: { title: z.string(), mood: z.string().optional() },
},
({ title, mood }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -0,0 +1,10 @@
{
"name": "mcp-server",
"version": "1.0.0",
"mcpServers": {
"nodeServer": {
"command": "node",
"args": ["${extensionPath}${/}example.ts"]
}
}
}

View File

@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import {
installExtension,
type ExtensionInstallMetadata,
} from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface InstallArgs {
path: string;
}
export async function handleLink(args: InstallArgs) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: args.path,
type: 'link',
};
const extensionName = await installExtension(installMetadata);
console.log(
`Extension "${extensionName}" linked successfully and enabled.`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
'Links an extension from a local path. Updates made to the local path will always be reflected.',
builder: (yargs) =>
yargs
.positional('path', {
describe: 'The name of the extension to link.',
type: 'string',
})
.check((_) => true),
handler: async (argv) => {
await handleLink({
path: argv['path'] as string,
});
},
};

View File

@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { newCommand } from './new.js';
import yargs from 'yargs';
import * as fsPromises from 'node:fs/promises';
vi.mock('node:fs/promises');
const mockedFs = vi.mocked(fsPromises);
describe('extensions new command', () => {
beforeEach(() => {
vi.resetAllMocks();
const fakeFiles = [
{ name: 'context', isDirectory: () => true },
{ name: 'custom-commands', isDirectory: () => true },
{ name: 'mcp-server', isDirectory: () => true },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockedFs.readdir.mockResolvedValue(fakeFiles as any);
});
it('should fail if no path is provided', async () => {
const parser = yargs([]).command(newCommand).fail(false);
await expect(parser.parseAsync('new')).rejects.toThrow(
'Not enough non-option arguments: got 0, need at least 2',
);
});
it('should fail if no template is provided', async () => {
const parser = yargs([]).command(newCommand).fail(false);
await expect(parser.parseAsync('new /some/path')).rejects.toThrow(
'Not enough non-option arguments: got 1, need at least 2',
);
});
it('should create directory and copy files when path does not exist', async () => {
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.cp.mockResolvedValue(undefined);
const parser = yargs([]).command(newCommand).fail(false);
await parser.parseAsync('new /some/path context');
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
recursive: true,
});
expect(mockedFs.cp).toHaveBeenCalledWith(
expect.stringContaining('context'),
'/some/path',
{ recursive: true },
);
});
it('should throw an error if the path already exists', async () => {
mockedFs.access.mockResolvedValue(undefined);
const parser = yargs([]).command(newCommand).fail(false);
await expect(parser.parseAsync('new /some/path context')).rejects.toThrow(
'Path already exists: /some/path',
);
expect(mockedFs.mkdir).not.toHaveBeenCalled();
expect(mockedFs.cp).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { access, cp, mkdir, readdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import type { CommandModule } from 'yargs';
import { fileURLToPath } from 'node:url';
import { getErrorMessage } from '../../utils/errors.js';
interface NewArgs {
path: string;
template: string;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const EXAMPLES_PATH = join(__dirname, 'examples');
async function pathExists(path: string) {
try {
await access(path);
return true;
} catch (_e) {
return false;
}
}
async function copyDirectory(template: string, path: string) {
if (await pathExists(path)) {
throw new Error(`Path already exists: ${path}`);
}
const examplePath = join(EXAMPLES_PATH, template);
await mkdir(path, { recursive: true });
await cp(examplePath, path, { recursive: true });
}
async function handleNew(args: NewArgs) {
try {
await copyDirectory(args.template, args.path);
console.log(
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
);
console.log(
`You can install this using "gemini extensions link ${args.path}" to test it out.`,
);
} catch (error) {
console.error(getErrorMessage(error));
throw error;
}
}
async function getBoilerplateChoices() {
const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
export const newCommand: CommandModule = {
command: 'new <path> <template>',
describe: 'Create a new extension from a boilerplate example.',
builder: async (yargs) => {
const choices = await getBoilerplateChoices();
return yargs
.positional('path', {
describe: 'The path to create the extension in.',
type: 'string',
})
.positional('template', {
describe: 'The boilerplate template to use.',
type: 'string',
choices,
});
},
handler: async (args) => {
await handleNew({
path: args['path'] as string,
template: args['template'] as string,
});
},
};

View File

@@ -32,7 +32,6 @@ export async function handleUpdate(args: UpdateArgs) {
} catch (error) {
console.error(getErrorMessage(error));
}
return;
}
if (args.name)
try {

View File

@@ -217,6 +217,36 @@ describe('loadExtensions', () => {
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
const extensionName = await installExtension({
source: sourceExtDir,
type: 'link',
});
expect(extensionName).toEqual('my-linked-extension');
const extensions = loadExtensions(tempHomeDir);
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
expect(linkedExt.config.name).toBe('my-linked-extension');
expect(linkedExt.path).toBe(sourceExtDir);
expect(linkedExt.installMetadata).toEqual({
source: sourceExtDir,
type: 'link',
});
expect(linkedExt.contextFiles).toEqual([
path.join(sourceExtDir, 'context.md'),
]);
});
it('should resolve environment variables in extension configuration', () => {
process.env.TEST_API_KEY = 'test-api-key-123';
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
@@ -402,12 +432,12 @@ describe('installExtension', () => {
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
});
it('should install an extension from a local path', async () => {
@@ -487,6 +517,31 @@ describe('installExtension', () => {
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should install a linked extension', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
await installExtension({ source: sourceExtDir, type: 'link' });
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
expect(fs.existsSync(configPath)).toBe(false);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'link',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
});
describe('uninstallExtension', () => {
@@ -701,7 +756,9 @@ function createExtension({
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
const contextPath = path.join(extDir, contextFileName);
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
fs.writeFileSync(contextPath, 'context');
}
return extDir;
}

View File

@@ -41,7 +41,7 @@ export interface ExtensionConfig {
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local';
type: 'git' | 'local' | 'link';
}
export interface ExtensionUpdateInfo {
@@ -175,10 +175,20 @@ export function loadExtension(extensionDir: string): Extension | null {
return null;
}
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
}
const configFilePath = path.join(
effectiveExtensionPath,
EXTENSIONS_CONFIG_FILENAME,
);
if (!fs.existsSync(configFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
`Warning: extension directory ${effectiveExtensionPath} does not contain a config file ${configFilePath}.`,
);
return null;
}
@@ -200,14 +210,16 @@ export function loadExtension(extensionDir: string): Extension | null {
config = resolveEnvVarsInObject(config);
const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName))
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
path: extensionDir,
path: effectiveExtensionPath,
config,
contextFiles,
installMetadata: loadInstallMetadata(extensionDir),
installMetadata,
};
} catch (e) {
console.error(
@@ -343,32 +355,38 @@ export async function installExtension(
// Convert relative paths to absolute paths for the metadata file.
if (
installMetadata.type === 'local' &&
!path.isAbsolute(installMetadata.source)
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let localSourcePath: string;
let tempDir: string | undefined;
let newExtensionName: string | undefined;
if (installMetadata.type === 'git') {
tempDir = await ExtensionStorage.createTmpDir();
await cloneFromGit(installMetadata.source, tempDir);
localSourcePath = tempDir;
} else {
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
localSourcePath = installMetadata.source;
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
let newExtensionName: string | undefined;
try {
const newExtension = loadExtension(localSourcePath);
if (!newExtension) {
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
if (!newExtensionConfig) {
throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
);
}
// ~/.gemini/extensions/{ExtensionConfig.name}.
newExtensionName = newExtension.config.name;
newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
@@ -383,7 +401,11 @@ export async function installExtension(
);
}
await copyExtension(localSourcePath, destinationPath);
await fs.promises.mkdir(destinationPath, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
await copyExtension(localSourcePath, destinationPath);
}
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
@@ -397,6 +419,29 @@ export async function installExtension(
return newExtensionName;
}
async function loadExtensionConfig(
extensionDir: string,
): Promise<ExtensionConfig | null> {
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
return null;
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
'/': path.sep,
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
return null;
}
return config;
} catch (_) {
return null;
}
}
export async function uninstallExtension(
extensionName: string,
cwd: string = process.cwd(),
@@ -425,7 +470,7 @@ export function toOutputString(extension: Extension): string {
let output = `${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source}`;
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
}
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
@@ -471,14 +516,21 @@ export async function updateExtension(
if (!extension.installMetadata) {
throw new Error(`Extension ${extension.config.name} cannot be updated.`);
}
if (extension.installMetadata.type === 'link') {
throw new Error(`Extension is linked so does not need to be updated`);
}
const originalVersion = extension.config.version;
const tempDir = await ExtensionStorage.createTmpDir();
try {
await copyExtension(extension.path, tempDir);
await uninstallExtension(extension.config.name, cwd);
await installExtension(extension.installMetadata, cwd);
const updatedExtension = loadExtension(extension.path);
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
const updatedExtension = loadExtension(
updatedExtensionStorage.getExtensionDir(),
);
if (!updatedExtension) {
throw new Error('Updated extension not found after installation.');
}

View File

@@ -53,4 +53,25 @@ if (!fs.existsSync(sourceDir)) {
}
copyFilesRecursive(sourceDir, targetDir);
// Copy example extensions into the bundle.
const packageName = path.basename(process.cwd());
if (packageName === 'cli') {
const examplesSource = path.join(
sourceDir,
'commands',
'extensions',
'examples',
);
const examplesTarget = path.join(
targetDir,
'commands',
'extensions',
'examples',
);
if (fs.existsSync(examplesSource)) {
fs.cpSync(examplesSource, examplesTarget, { recursive: true });
}
}
console.log('Successfully copied files.');