diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 733866a185..e198a1ac47 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -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 ', @@ -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: () => { diff --git a/packages/cli/src/commands/extensions/examples/context/GEMINI.md b/packages/cli/src/commands/extensions/examples/context/GEMINI.md new file mode 100644 index 0000000000..22f6bbce5e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/context/GEMINI.md @@ -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 and to provide semantic meaning to screen readers. diff --git a/packages/cli/src/commands/extensions/examples/context/gemini-extension.json b/packages/cli/src/commands/extensions/examples/context/gemini-extension.json new file mode 100644 index 0000000000..c3fee9836e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/context/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "context-example", + "version": "1.0.0", + "contextFileName": "GEMINI.md" +} diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml new file mode 100644 index 0000000000..87d957542a --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml @@ -0,0 +1,6 @@ +prompt = """ +Please summarize the findings for the pattern `{{args}}`. + +Search Results: +!{grep -r {{args}} .} +""" diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json b/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json new file mode 100644 index 0000000000..d973ab8fe4 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "custom-commands", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json b/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json new file mode 100644 index 0000000000..5023fb7ad0 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "excludeTools", + "version": "1.0.0", + "excludeTools": ["run_shell_command(rm -rf)"] +} diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/example.ts b/packages/cli/src/commands/extensions/examples/mcp-server/example.ts new file mode 100644 index 0000000000..21e01e17cb --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/example.ts @@ -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); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json new file mode 100644 index 0000000000..27c5e36c24 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json @@ -0,0 +1,10 @@ +{ + "name": "mcp-server", + "version": "1.0.0", + "mcpServers": { + "nodeServer": { + "command": "node", + "args": ["${extensionPath}${/}example.ts"] + } + } +} diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts new file mode 100644 index 0000000000..034e94d1c8 --- /dev/null +++ b/packages/cli/src/commands/extensions/link.ts @@ -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 ', + 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, + }); + }, +}; diff --git a/packages/cli/src/commands/extensions/new.test.ts b/packages/cli/src/commands/extensions/new.test.ts new file mode 100644 index 0000000000..e5413310d5 --- /dev/null +++ b/packages/cli/src/commands/extensions/new.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts new file mode 100644 index 0000000000..c502e8813f --- /dev/null +++ b/packages/cli/src/commands/extensions/new.ts @@ -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