mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
feat(cli): support custom skill discovery paths for Open Plugins
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
||||
createOpenPlugin,
|
||||
type OpenPluginConfig,
|
||||
OPEN_PLUGIN_NAME_REGEX,
|
||||
resolvePluginSkills,
|
||||
type OpenPluginDiscoveryField,
|
||||
} from './plugin.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
logExtensionUninstall,
|
||||
logExtensionUpdateEvent,
|
||||
loadSkillsFromDir,
|
||||
type SkillDefinition,
|
||||
loadAgentsFromDirectory,
|
||||
homedir,
|
||||
ExtensionIntegrityManager,
|
||||
@@ -343,9 +346,30 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
Object.keys(previous.hooks).length > 0
|
||||
);
|
||||
|
||||
const newSkills = await loadSkillsFromDir(
|
||||
path.join(localSourcePath, 'skills'),
|
||||
);
|
||||
let newSkills: SkillDefinition[] = [];
|
||||
if (newExtensionConfig.manifestType === 'open-plugin') {
|
||||
const openPluginConfig = newExtensionConfig as OpenPluginConfig & {
|
||||
skills?: OpenPluginDiscoveryField;
|
||||
};
|
||||
const hydrationContext = {
|
||||
extensionPath: localSourcePath,
|
||||
PLUGIN_ROOT: localSourcePath,
|
||||
workspacePath: this.workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
};
|
||||
const resolvedSkills = await resolvePluginSkills(
|
||||
localSourcePath,
|
||||
newExtensionConfig.name,
|
||||
hydrationContext,
|
||||
openPluginConfig.skills,
|
||||
);
|
||||
newSkills = resolvedSkills || [];
|
||||
} else {
|
||||
newSkills = await loadSkillsFromDir(
|
||||
path.join(localSourcePath, 'skills'),
|
||||
);
|
||||
}
|
||||
const previousSkills = previous?.skills ?? [];
|
||||
const isMigrating = Boolean(
|
||||
previous &&
|
||||
|
||||
@@ -131,7 +131,8 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
|
||||
it('should load skills for Open Plugins', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'feature-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const skillsDir = path.join(pluginDir, 'skills', 'test-skill');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
@@ -161,6 +162,161 @@ Body`,
|
||||
expect(plugin?.skills?.[0].extensionName).toBe('feature-plugin');
|
||||
});
|
||||
|
||||
it('should load skills from custom path specified as string', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'custom-path-plugin');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const skillsDir = path.join(pluginDir, 'custom-skills', 'test-skill');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'custom-path-plugin',
|
||||
version: '1.0.0',
|
||||
skills: './custom-skills',
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(skillsDir, 'SKILL.md'),
|
||||
`---
|
||||
name: my-skill
|
||||
description: "Test description"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'custom-path-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.skills).toBeDefined();
|
||||
expect(plugin?.skills?.[0].name).toBe('custom-path-plugin:my-skill');
|
||||
});
|
||||
|
||||
it('should load skills from custom paths specified as array', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'array-path-plugin');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const skillsDir1 = path.join(pluginDir, 'skills1', 'skill1');
|
||||
const skillsDir2 = path.join(pluginDir, 'skills2', 'skill2');
|
||||
fs.mkdirSync(skillsDir1, { recursive: true });
|
||||
fs.mkdirSync(skillsDir2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'array-path-plugin',
|
||||
version: '1.0.0',
|
||||
skills: ['./skills1', './skills2'],
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(skillsDir1, 'SKILL.md'),
|
||||
`---
|
||||
name: skill1
|
||||
description: "Desc 1"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(skillsDir2, 'SKILL.md'),
|
||||
`---
|
||||
name: skill2
|
||||
description: "Desc 2"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'array-path-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.skills).toHaveLength(2);
|
||||
const skillNames = plugin?.skills?.map((s) => s.name);
|
||||
expect(skillNames).toContain('array-path-plugin:skill1');
|
||||
expect(skillNames).toContain('array-path-plugin:skill2');
|
||||
});
|
||||
|
||||
it('should load skills from custom paths specified as object', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'object-path-plugin');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const skillsDir = path.join(pluginDir, 'obj-skills', 'skill');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'object-path-plugin',
|
||||
version: '1.0.0',
|
||||
skills: { paths: ['./obj-skills'] },
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(skillsDir, 'SKILL.md'),
|
||||
`---
|
||||
name: skill1
|
||||
description: "Desc 1"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'object-path-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.skills?.[0].name).toBe('object-path-plugin:skill1');
|
||||
});
|
||||
|
||||
it('should NOT load skills from default location if custom paths are specified', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'override-plugin');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const defaultDir = path.join(pluginDir, 'skills', 'skill1');
|
||||
const customDir = path.join(pluginDir, 'custom-skills', 'skill2');
|
||||
fs.mkdirSync(defaultDir, { recursive: true });
|
||||
fs.mkdirSync(customDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'override-plugin',
|
||||
version: '1.0.0',
|
||||
skills: './custom-skills',
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(defaultDir, 'SKILL.md'),
|
||||
`---
|
||||
name: skill1
|
||||
description: "Default"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(customDir, 'SKILL.md'),
|
||||
`---
|
||||
name: skill2
|
||||
description: "Custom"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'override-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.skills).toHaveLength(1);
|
||||
expect(plugin?.skills?.[0].name).toBe('override-plugin:skill2');
|
||||
});
|
||||
|
||||
it('should prioritize gemini-extension.json over plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'dual-manifest-plugin');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
loadSkillsFromDir,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
type SkillDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
@@ -117,7 +118,7 @@ export async function loadOpenPluginConfig(
|
||||
manifestPath: string,
|
||||
extensionDir: string,
|
||||
workspaceDir: string,
|
||||
): Promise<ExtensionConfig> {
|
||||
): Promise<ExtensionConfig & { skills?: OpenPluginDiscoveryField }> {
|
||||
const content = await fs.promises.readFile(manifestPath, 'utf-8');
|
||||
const json = JSON.parse(content) as unknown;
|
||||
const result = openPluginSchema.safeParse(json);
|
||||
@@ -151,6 +152,7 @@ export async function loadOpenPluginConfig(
|
||||
keywords: hydratedConfig.keywords,
|
||||
homepage: hydratedConfig.homepage,
|
||||
repository: hydratedConfig.repository,
|
||||
skills: hydratedConfig.skills,
|
||||
// Features are explicitly NOT mapped here for v1 plugins
|
||||
};
|
||||
}
|
||||
@@ -185,6 +187,7 @@ export async function createOpenPlugin(
|
||||
pluginDir,
|
||||
config.name,
|
||||
hydrationContext,
|
||||
config.skills,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -216,19 +219,41 @@ export async function createOpenPlugin(
|
||||
/**
|
||||
* Discovers and namespaces skills for an Open Plugin.
|
||||
*/
|
||||
async function resolvePluginSkills(
|
||||
export async function resolvePluginSkills(
|
||||
pluginDir: string,
|
||||
pluginName: string,
|
||||
hydrationContext: Record<string, string>,
|
||||
skillsConfig?: OpenPluginDiscoveryField,
|
||||
): Promise<GeminiCLIExtension['skills']> {
|
||||
const skillsDir = path.join(pluginDir, 'skills');
|
||||
const discoveredSkills = await loadSkillsFromDir(skillsDir);
|
||||
let resolvedPaths: string[] = [];
|
||||
|
||||
if (discoveredSkills.length === 0) {
|
||||
if (skillsConfig) {
|
||||
if (typeof skillsConfig === 'string') {
|
||||
resolvedPaths = [skillsConfig];
|
||||
} else if (Array.isArray(skillsConfig)) {
|
||||
resolvedPaths = skillsConfig;
|
||||
} else if (typeof skillsConfig === 'object' && 'paths' in skillsConfig) {
|
||||
resolvedPaths = skillsConfig.paths;
|
||||
}
|
||||
} else {
|
||||
resolvedPaths = ['./skills/'];
|
||||
}
|
||||
|
||||
const allDiscoveredSkills: SkillDefinition[] = [];
|
||||
|
||||
for (const relPath of resolvedPaths) {
|
||||
const absPath = path.resolve(pluginDir, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
const discovered = await loadSkillsFromDir(absPath);
|
||||
allDiscoveredSkills.push(...discovered);
|
||||
}
|
||||
}
|
||||
|
||||
if (allDiscoveredSkills.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return discoveredSkills.map((skill) => ({
|
||||
return allDiscoveredSkills.map((skill) => ({
|
||||
...recursivelyHydrateStrings(skill, hydrationContext),
|
||||
name: `${pluginName}:${skill.name}`,
|
||||
extensionName: pluginName,
|
||||
|
||||
Reference in New Issue
Block a user