diff --git a/packages/cli/src/config/open-plugin-discovery.test.ts b/packages/cli/src/config/open-plugin-discovery.test.ts index 62dfb14c37..a2b31557c1 100644 --- a/packages/cli/src/config/open-plugin-discovery.test.ts +++ b/packages/cli/src/config/open-plugin-discovery.test.ts @@ -129,11 +129,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'); - const hiddenDir = path.join(pluginDir, '.plugin'); - fs.mkdirSync(hiddenDir, { recursive: true }); - const skillsDir = path.join(pluginDir, 'skills', 'test'); + fs.mkdirSync(pluginDir, { recursive: true }); + const skillsDir = path.join(pluginDir, 'skills', 'test-skill'); fs.mkdirSync(skillsDir, { recursive: true }); fs.writeFileSync( @@ -147,20 +146,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 () => { diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts index 15c6d742fa..64fe639f2b 100644 --- a/packages/cli/src/config/plugin.ts +++ b/packages/cli/src/config/plugin.ts @@ -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, @@ -156,7 +157,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, @@ -173,6 +173,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, @@ -193,8 +207,30 @@ export async function createOpenPlugin( 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, +): Promise { + 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, + })); +} diff --git a/packages/cli/src/services/SlashCommandResolver.test.ts b/packages/cli/src/services/SlashCommandResolver.test.ts index 40e3b6f1d5..e564ea7337 100644 --- a/packages/cli/src/services/SlashCommandResolver.test.ts +++ b/packages/cli/src/services/SlashCommandResolver.test.ts @@ -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); diff --git a/packages/cli/src/services/SlashCommandResolver.ts b/packages/cli/src/services/SlashCommandResolver.ts index e956d6f566..bee191978d 100644 --- a/packages/cli/src/services/SlashCommandResolver.ts +++ b/packages/cli/src/services/SlashCommandResolver.ts @@ -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;