diff --git a/src/plugin-catalog.test.ts b/src/plugin-catalog.test.ts index eedb6dff..6d2065de 100644 --- a/src/plugin-catalog.test.ts +++ b/src/plugin-catalog.test.ts @@ -119,4 +119,27 @@ describe('plugin catalog', () => { expect(result.plugins.map((plugin) => plugin.name)).toEqual(['flights']); expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]); }); + + it('matches normalized plugin names across query separators and order', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'agentrhq/webcmd', source: 'github:agentrhq/webcmd', manifestUrl: 'https://example.com/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Tools for technology discussions' } } }); + + for (const query of ['hacker news', 'hacker-news', 'hacker.news', 'hackernews', 'hacker', 'news hacker']) { + const result = await searchCatalogPlugins(catalog, { query, fetchJson }); + expect(result.plugins.map((plugin) => plugin.name), `query: ${query}`).toEqual(['hackernews']); + } + }); + + it('requires every query token to match', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'agentrhq/webcmd', source: 'github:agentrhq/webcmd', manifestUrl: 'https://example.com/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Tools for technology discussions' } } }); + const miss = await searchCatalogPlugins(catalog, { query: 'reddit news', fetchJson }); + expect(miss.plugins).toEqual([]); + }); }); diff --git a/src/plugin-catalog.ts b/src/plugin-catalog.ts index c8b009f3..64296349 100644 --- a/src/plugin-catalog.ts +++ b/src/plugin-catalog.ts @@ -152,7 +152,7 @@ export async function searchCatalogPlugins( const query = options.query?.trim().toLowerCase(); const filtered = query - ? plugins.filter((plugin) => `${plugin.name} ${plugin.description ?? ''}`.toLowerCase().includes(query)) + ? plugins.filter((plugin) => matchesSearchQuery(plugin, query)) : plugins; filtered.sort((a, b) => a.name.localeCompare(b.name)); @@ -160,6 +160,22 @@ export async function searchCatalogPlugins( return { plugins: filtered, errors }; } +/** Strip everything but letters/digits so separators (spaces, hyphens, ...) can't hide a match. */ +function normalizeForSearch(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +/** + * Multi-word queries must match as independent tokens, not one literal substring — "hacker + * news" should still find the "hackernews" plugin. Each token is matched separately so word + * order in the query doesn't matter either. + */ +function matchesSearchQuery(plugin: PluginSearchRow, query: string): boolean { + const haystack = normalizeForSearch(`${plugin.name} ${plugin.description ?? ''}`); + const tokens = query.split(/[^a-z0-9]+/).filter(Boolean); + return tokens.every((token) => haystack.includes(token)); +} + function seedUserCatalog(packageRoot: string | undefined, homeDir: string): void { const packagedPath = getPackagedPluginCatalogPath(packageRoot); const userPath = getUserPluginCatalogPath(homeDir);