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 {