From 616a1cc77926a7ba6e534018593fdc1da809729b Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:23:16 +0530 Subject: [PATCH 1/2] fix: tokenize plugin search queries instead of matching one literal substring searchCatalogPlugins tested the whole query as a single substring of "name description", so multi-word queries like "hacker news" missed a one-word plugin name like hackernews even though "hacker" alone found it. Strip non-alphanumeric separators from both sides and require each query token to match independently, so word order and separators (space, hyphen, concatenation) no longer matter. Fixes #282 --- src/plugin-catalog.test.ts | 16 ++++++++++++++++ src/plugin-catalog.ts | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/plugin-catalog.test.ts b/src/plugin-catalog.test.ts index eedb6dff..62ca6b23 100644 --- a/src/plugin-catalog.test.ts +++ b/src/plugin-catalog.test.ts @@ -119,4 +119,20 @@ 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 multi-word queries against a one-word plugin name, ignoring 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: 'Hacker News client' } } }); + + for (const query of ['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']); + } + + 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); From 5faba9ec284b2aa0ac5a537cba50dbb6d0012d70 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 13 Aug 2026 16:29:57 +0530 Subject: [PATCH 2/2] test: strengthen multi-word plugin search coverage Incorporate the name-focused fixture from #284 and the separated all-terms assertion from #288. --- src/plugin-catalog.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/plugin-catalog.test.ts b/src/plugin-catalog.test.ts index 62ca6b23..6d2065de 100644 --- a/src/plugin-catalog.test.ts +++ b/src/plugin-catalog.test.ts @@ -120,18 +120,25 @@ describe('plugin catalog', () => { expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]); }); - it('matches multi-word queries against a one-word plugin name, ignoring separators and order', async () => { + 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: 'Hacker News client' } } }); + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Tools for technology discussions' } } }); - for (const query of ['hacker news', 'hacker-news', 'hackernews', 'hacker', 'news hacker']) { + 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([]); });