From 5009a99754c38da39f9527568dfb554fb083d99a Mon Sep 17 00:00:00 2001 From: ruomeng Date: Tue, 14 Apr 2026 13:59:52 -0400 Subject: [PATCH] feat(cli): support custom skill discovery paths for Open Plugins --- packages/cli/src/config/extension-manager.ts | 30 +++- .../src/config/open-plugin-discovery.test.ts | 158 +++++++++++++++++- packages/cli/src/config/plugin.ts | 37 +++- 3 files changed, 215 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 3f15ac1e1b..4890444f04 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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 && diff --git a/packages/cli/src/config/open-plugin-discovery.test.ts b/packages/cli/src/config/open-plugin-discovery.test.ts index a2b31557c1..a2a8700f65 100644 --- a/packages/cli/src/config/open-plugin-discovery.test.ts +++ b/packages/cli/src/config/open-plugin-discovery.test.ts @@ -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'); diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts index 64fe639f2b..558db3c1b1 100644 --- a/packages/cli/src/config/plugin.ts +++ b/packages/cli/src/config/plugin.ts @@ -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 { +): Promise { 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, + skillsConfig?: OpenPluginDiscoveryField, ): Promise { - 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,