mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 05:55:17 -07:00
Add gemini extensions link command (#7241)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import { listCommand } from './extensions/list.js';
|
|||||||
import { updateCommand } from './extensions/update.js';
|
import { updateCommand } from './extensions/update.js';
|
||||||
import { disableCommand } from './extensions/disable.js';
|
import { disableCommand } from './extensions/disable.js';
|
||||||
import { enableCommand } from './extensions/enable.js';
|
import { enableCommand } from './extensions/enable.js';
|
||||||
|
import { linkCommand } from './extensions/link.js';
|
||||||
|
import { newCommand } from './extensions/new.js';
|
||||||
|
|
||||||
export const extensionsCommand: CommandModule = {
|
export const extensionsCommand: CommandModule = {
|
||||||
command: 'extensions <command>',
|
command: 'extensions <command>',
|
||||||
@@ -23,6 +25,8 @@ export const extensionsCommand: CommandModule = {
|
|||||||
.command(updateCommand)
|
.command(updateCommand)
|
||||||
.command(disableCommand)
|
.command(disableCommand)
|
||||||
.command(enableCommand)
|
.command(enableCommand)
|
||||||
|
.command(linkCommand)
|
||||||
|
.command(newCommand)
|
||||||
.demandCommand(1, 'You need at least one command before continuing.')
|
.demandCommand(1, 'You need at least one command before continuing.')
|
||||||
.version(false),
|
.version(false),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "context-example",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"contextFileName": "GEMINI.md"
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
prompt = """
|
||||||
|
Please summarize the findings for the pattern `{{args}}`.
|
||||||
|
|
||||||
|
Search Results:
|
||||||
|
!{grep -r {{args}} .}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "custom-commands",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "excludeTools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"excludeTools": ["run_shell_command(rm -rf)"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"mcpServers": {
|
||||||
|
"nodeServer": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["${extensionPath}${/}example.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 <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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -32,7 +32,6 @@ export async function handleUpdate(args: UpdateArgs) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(getErrorMessage(error));
|
console.error(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (args.name)
|
if (args.name)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -217,6 +217,36 @@ describe('loadExtensions', () => {
|
|||||||
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
|
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', () => {
|
it('should resolve environment variables in extension configuration', () => {
|
||||||
process.env.TEST_API_KEY = 'test-api-key-123';
|
process.env.TEST_API_KEY = 'test-api-key-123';
|
||||||
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
||||||
@@ -402,12 +432,12 @@ describe('installExtension', () => {
|
|||||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||||
|
|
||||||
vi.mocked(execSync).mockClear();
|
vi.mocked(execSync).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a local path', async () => {
|
it('should install an extension from a local path', async () => {
|
||||||
@@ -487,6 +517,31 @@ describe('installExtension', () => {
|
|||||||
});
|
});
|
||||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
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', () => {
|
describe('uninstallExtension', () => {
|
||||||
@@ -701,7 +756,9 @@ function createExtension({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contextFileName) {
|
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;
|
return extDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface ExtensionConfig {
|
|||||||
|
|
||||||
export interface ExtensionInstallMetadata {
|
export interface ExtensionInstallMetadata {
|
||||||
source: string;
|
source: string;
|
||||||
type: 'git' | 'local';
|
type: 'git' | 'local' | 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionUpdateInfo {
|
export interface ExtensionUpdateInfo {
|
||||||
@@ -175,10 +175,20 @@ export function loadExtension(extensionDir: string): Extension | null {
|
|||||||
return 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)) {
|
if (!fs.existsSync(configFilePath)) {
|
||||||
console.error(
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -200,14 +210,16 @@ export function loadExtension(extensionDir: string): Extension | null {
|
|||||||
config = resolveEnvVarsInObject(config);
|
config = resolveEnvVarsInObject(config);
|
||||||
|
|
||||||
const contextFiles = getContextFileNames(config)
|
const contextFiles = getContextFileNames(config)
|
||||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
.map((contextFileName) =>
|
||||||
|
path.join(effectiveExtensionPath, contextFileName),
|
||||||
|
)
|
||||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: extensionDir,
|
path: effectiveExtensionPath,
|
||||||
config,
|
config,
|
||||||
contextFiles,
|
contextFiles,
|
||||||
installMetadata: loadInstallMetadata(extensionDir),
|
installMetadata,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -343,32 +355,38 @@ export async function installExtension(
|
|||||||
|
|
||||||
// Convert relative paths to absolute paths for the metadata file.
|
// Convert relative paths to absolute paths for the metadata file.
|
||||||
if (
|
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);
|
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||||
}
|
}
|
||||||
|
|
||||||
let localSourcePath: string;
|
let localSourcePath: string;
|
||||||
let tempDir: string | undefined;
|
let tempDir: string | undefined;
|
||||||
|
let newExtensionName: string | undefined;
|
||||||
|
|
||||||
if (installMetadata.type === 'git') {
|
if (installMetadata.type === 'git') {
|
||||||
tempDir = await ExtensionStorage.createTmpDir();
|
tempDir = await ExtensionStorage.createTmpDir();
|
||||||
await cloneFromGit(installMetadata.source, tempDir);
|
await cloneFromGit(installMetadata.source, tempDir);
|
||||||
localSourcePath = tempDir;
|
localSourcePath = tempDir;
|
||||||
} else {
|
} else if (
|
||||||
|
installMetadata.type === 'local' ||
|
||||||
|
installMetadata.type === 'link'
|
||||||
|
) {
|
||||||
localSourcePath = installMetadata.source;
|
localSourcePath = installMetadata.source;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||||
}
|
}
|
||||||
let newExtensionName: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const newExtension = loadExtension(localSourcePath);
|
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||||
if (!newExtension) {
|
if (!newExtensionConfig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ~/.gemini/extensions/{ExtensionConfig.name}.
|
newExtensionName = newExtensionConfig.name;
|
||||||
newExtensionName = newExtension.config.name;
|
|
||||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||||
const destinationPath = extensionStorage.getExtensionDir();
|
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 metadataString = JSON.stringify(installMetadata, null, 2);
|
||||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||||
@@ -397,6 +419,29 @@ export async function installExtension(
|
|||||||
return newExtensionName;
|
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(
|
export async function uninstallExtension(
|
||||||
extensionName: string,
|
extensionName: string,
|
||||||
cwd: string = process.cwd(),
|
cwd: string = process.cwd(),
|
||||||
@@ -425,7 +470,7 @@ export function toOutputString(extension: Extension): string {
|
|||||||
let output = `${extension.config.name} (${extension.config.version})`;
|
let output = `${extension.config.name} (${extension.config.version})`;
|
||||||
output += `\n Path: ${extension.path}`;
|
output += `\n Path: ${extension.path}`;
|
||||||
if (extension.installMetadata) {
|
if (extension.installMetadata) {
|
||||||
output += `\n Source: ${extension.installMetadata.source}`;
|
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||||
}
|
}
|
||||||
if (extension.contextFiles.length > 0) {
|
if (extension.contextFiles.length > 0) {
|
||||||
output += `\n Context files:`;
|
output += `\n Context files:`;
|
||||||
@@ -471,14 +516,21 @@ export async function updateExtension(
|
|||||||
if (!extension.installMetadata) {
|
if (!extension.installMetadata) {
|
||||||
throw new Error(`Extension ${extension.config.name} cannot be updated.`);
|
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 originalVersion = extension.config.version;
|
||||||
|
|
||||||
const tempDir = await ExtensionStorage.createTmpDir();
|
const tempDir = await ExtensionStorage.createTmpDir();
|
||||||
try {
|
try {
|
||||||
await copyExtension(extension.path, tempDir);
|
await copyExtension(extension.path, tempDir);
|
||||||
await uninstallExtension(extension.config.name, cwd);
|
await uninstallExtension(extension.config.name, cwd);
|
||||||
await installExtension(extension.installMetadata, 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) {
|
if (!updatedExtension) {
|
||||||
throw new Error('Updated extension not found after installation.');
|
throw new Error('Updated extension not found after installation.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,25 @@ if (!fs.existsSync(sourceDir)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copyFilesRecursive(sourceDir, targetDir);
|
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.');
|
console.log('Successfully copied files.');
|
||||||
|
|||||||
Reference in New Issue
Block a user