feat(cli): support skill discovery for Open Plugins

- Updated skillLoader to support discovery in skills/ subdirectories
- Implemented convention-based skill discovery for Open Plugins
- Enforced namespacing for plugin skills (plugin:skill-name)
- Refactored skill resolution into resolvePluginSkills for better maintainability
- Added comprehensive tests for Open Plugin skill discovery

Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1592
This commit is contained in:
Taylor Mullen
2026-03-23 16:47:34 -07:00
parent 258490b19c
commit c64c08c774
4 changed files with 72 additions and 17 deletions
@@ -134,10 +134,10 @@ describe('ExtensionManager - Open Plugin Support', () => {
expect(plugin?.description).toBe(`Uses root: ${pluginDir}`);
});
it('should NOT load skills or context files for Open Plugins in v1', async () => {
it('should load skills for Open Plugins', async () => {
const pluginDir = path.join(userExtensionsDir, 'feature-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
const skillsDir = path.join(pluginDir, 'skills', 'test');
const skillsDir = path.join(pluginDir, 'skills', 'test-skill');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(
@@ -151,20 +151,19 @@ describe('ExtensionManager - Open Plugin Support', () => {
fs.writeFileSync(
path.join(skillsDir, 'SKILL.md'),
`---
name: test-skill
description: "Test"
---
Body`,
name: my-skill
description: "Test description"
---
Body`,
);
fs.writeFileSync(path.join(pluginDir, 'GEMINI.md'), '# Context');
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'feature-plugin');
expect(plugin).toBeDefined();
expect(plugin?.skills).toBeUndefined();
expect(plugin?.contextFiles).toEqual([]);
expect(plugin?.skills).toBeDefined();
expect(plugin?.skills?.[0].name).toBe('feature-plugin:my-skill');
expect(plugin?.skills?.[0].extensionName).toBe('feature-plugin');
});
it('should prioritize gemini-extension.json over plugin.json', async () => {
+41 -6
View File
@@ -7,9 +7,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { z } from 'zod';
import type {
ExtensionInstallMetadata,
GeminiCLIExtension,
import {
loadSkillsFromDir,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import {
EXTENSIONS_CONFIG_FILENAME,
@@ -131,7 +132,6 @@ export async function loadOpenPluginConfig(
/**
* Creates a GeminiCLIExtension from an Open Plugin directory.
* v1: Does not enable skills, mcp servers, context files, or settings.
*/
export async function createOpenPlugin(
pluginDir: string,
@@ -148,6 +148,20 @@ export async function createOpenPlugin(
workspaceDir,
);
const hydrationContext = {
extensionPath: pluginDir,
PLUGIN_ROOT: pluginDir,
workspacePath: workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
};
const skills = await resolvePluginSkills(
pluginDir,
config.name,
hydrationContext,
);
return {
name: config.name,
version: config.version,
@@ -159,14 +173,35 @@ export async function createOpenPlugin(
description: config.description,
author: config.author,
license: config.license,
// v1: Features disabled
contextFiles: [],
mcpServers: undefined,
excludeTools: undefined,
settings: undefined,
resolvedSettings: undefined,
skills: undefined,
skills,
agents: undefined,
themes: undefined,
};
}
/**
* Discovers and namespaces skills for an Open Plugin.
*/
async function resolvePluginSkills(
pluginDir: string,
pluginName: string,
hydrationContext: Record<string, string>,
): Promise<GeminiCLIExtension['skills']> {
const skillsDir = path.join(pluginDir, 'skills');
const discoveredSkills = await loadSkillsFromDir(skillsDir);
if (discoveredSkills.length === 0) {
return undefined;
}
return discoveredSkills.map((skill) => ({
...recursivelyHydrateStrings(skill, hydrationContext),
name: `${pluginName}:${skill.name}`,
extensionName: pluginName,
}));
}
@@ -218,6 +218,19 @@ describe('SlashCommandResolver', () => {
expect(names).toContain('google-workspace:chat1');
});
it('should NOT double-prefix when a skill name is already prefixed', () => {
const skill = {
...createMockCommand('google-workspace:chat', CommandKind.SKILL),
extensionName: 'google-workspace',
};
const { finalCommands } = SlashCommandResolver.resolve([skill]);
const names = finalCommands.map((c) => c.name);
expect(names).toContain('google-workspace:chat');
expect(names).not.toContain('google-workspace:google-workspace:chat');
});
it('should NOT prefix skills with "skill" when extension name is missing', () => {
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
const skill = createMockCommand('chat', CommandKind.SKILL);
@@ -173,7 +173,15 @@ export class SlashCommandResolver {
const isExtensionPrefix =
kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE;
const separator = isExtensionPrefix ? ':' : '.';
const base = prefix ? `${prefix}${separator}${name}` : name;
// Check if it's already correctly prefixed
const alreadyPrefixed = prefix && name.startsWith(`${prefix}${separator}`);
const base = alreadyPrefixed
? name
: prefix
? `${prefix}${separator}${name}`
: name;
let renamedName = base;
let suffix = 1;