diff --git a/src/builtin-command-surface.ts b/src/builtin-command-surface.ts index b827b33a..002fbcb6 100644 --- a/src/builtin-command-surface.ts +++ b/src/builtin-command-surface.ts @@ -23,8 +23,8 @@ export function configureCompletionCommandSurface(command: Command): Command { /** Configure plugin marketplace search grammar shared by local and hosted runtimes. */ export function configurePluginSearchSurface(command: Command): Command { return addOutputFormatOption(command - .description('Search installable marketplace plugins') - .argument('[query]', 'Search query matched against plugin name and description')); + .description('Search the plugin catalog for installable plugins. Not web search.') + .argument('[query]', 'Catalog query matched against plugin name and description, not a web search')); } /** Configure plugin installation grammar shared by local and hosted runtimes. */ diff --git a/src/cli.test.ts b/src/cli.test.ts index aed096a6..bbd50b8a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1265,6 +1265,8 @@ name: 'search', const install = plugin.commands.find(cmd => cmd.name() === 'install')!; expect(search.usage()).toBe('[options] [query]'); + expect(search.description()).toBe('Search the plugin catalog for installable plugins. Not web search.'); + expect(search.registeredArguments[0]?.description).toContain('not a web search'); expect(search.options.map(option => option.flags)).toContain('-f, --format '); expect(install.usage()).toBe('[options] '); expect(install.description()).toBe('Install a plugin from a git repository'); @@ -1272,6 +1274,53 @@ name: 'search', expect(install.options.map(option => option.long)).toContain('--all'); }); + it('labels empty plugin search JSON as catalog discovery, not web search', async () => { + const catalog = await import('./plugin-catalog.js'); + const search = vi.spyOn(catalog, 'searchCatalogPlugins').mockResolvedValue({ plugins: [], errors: [] }); + const read = vi.spyOn(catalog, 'readCatalog').mockReturnValue({ version: 1, sources: [] }); + const log = vi.mocked(console.log); + const previousExitCode = process.exitCode; + log.mockClear(); + try { + await createProgram('', '').parseAsync(['node', 'webcmd', 'plugin', 'search', 'tls fingerprint', '-f', 'json']); + + const payload = JSON.parse(log.mock.calls.flat().join('\n')); + expect(payload).toMatchObject({ + kind: 'plugin-catalog', + query: 'tls fingerprint', + total: 0, + plugins: [], + errors: [], + }); + expect(payload.hint).toContain('not web pages'); + expect(payload.hint).toContain('webcmd web fetch --url "https://html.duckduckgo.com/html/?q=tls%20fingerprint"'); + expect(search).toHaveBeenCalled(); + } finally { + process.exitCode = previousExitCode; + search.mockRestore(); + read.mockRestore(); + } + }); + + it('prints catalog-not-web copy for an empty plugin search table', async () => { + const catalog = await import('./plugin-catalog.js'); + const search = vi.spyOn(catalog, 'searchCatalogPlugins').mockResolvedValue({ plugins: [], errors: [] }); + const read = vi.spyOn(catalog, 'readCatalog').mockReturnValue({ version: 1, sources: [] }); + const log = vi.mocked(console.log); + const previousExitCode = process.exitCode; + log.mockClear(); + try { + await createProgram('', '').parseAsync(['node', 'webcmd', 'plugin', 'search', 'ja3', '-f', 'table']); + + expect(log.mock.calls.flat().join('\n')).toContain('No marketplace plugins matched "ja3". This command searches the plugin catalog, not the web.'); + expect(log.mock.calls.flat().join('\n')).toContain('not web pages'); + } finally { + process.exitCode = previousExitCode; + search.mockRestore(); + read.mockRestore(); + } + }); + it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => { const argv = process.argv; try { diff --git a/src/cli.ts b/src/cli.ts index ecd68d43..9db2a828 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { type CliCommand, getRegistry } from './registry.js'; import './fetch/command.js'; import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginListSurface, configurePluginSearchSurface } from './builtin-command-surface.js'; +import { formatPluginSearchEmptyCopy, presentPluginSearch } from './plugin-search-presentation.js'; import { addOutputFormatOption, applyUnknownOptionContract, CommanderStructuralError, outputFormatIsExplicit, resolveCommandOutputFormat } from './command-surface.js'; import { render as renderOutput, formatErrorEnvelope, errorEnvelopeFormat, requestedFormatFromArgv } from './output.js'; import { PKG_VERSION } from './version.js'; @@ -1658,17 +1659,22 @@ cli({ const catalog = readCatalog(); const result = await searchCatalogPlugins(catalog, { query }); const fmtExplicit = outputFormatIsExplicit(pluginSearchCmd); + const presented = presentPluginSearch(result, query); if (fmt === 'json') { - renderOutput(result, { fmt }); + renderOutput(presented, { fmt }); } else { for (const err of result.errors) console.error(`Warning: ${err.sourceId}: ${err.message}`); - renderOutput(result.plugins, { - fmt, - fmtExplicit, - columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd'], - title: `${CLI_COMMAND}/plugin-search`, - source: `${CLI_COMMAND} plugin search`, - }); + if (presented.total === 0) { + console.log(formatPluginSearchEmptyCopy(presented.query)); + } else { + renderOutput(result.plugins, { + fmt, + fmtExplicit, + columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd'], + title: `${CLI_COMMAND}/plugin-search`, + source: `${CLI_COMMAND} plugin search`, + }); + } } if (catalog.sources.length > 0 && result.errors.length === catalog.sources.length) { process.exitCode = EXIT_CODES.GENERIC_ERROR; diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 632dbb58..37d5057f 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -465,6 +465,33 @@ describe('runHostedCli', () => { expect(requests).toEqual(['https://api.example.com/v1/marketplace/plugins?query=mercury']); }); + it('labels empty hosted plugin search JSON as catalog discovery, not web search', async () => { + const stdout = sink(); + const stderr = sink(); + const result = await runHostedCli(['plugin', 'search', 'tls fingerprint', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + result: { plugins: [], errors: [] }, + })), + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stderr.text()).toBe(''); + const payload = JSON.parse(stdout.text()); + expect(payload).toMatchObject({ + kind: 'plugin-catalog', + query: 'tls fingerprint', + total: 0, + plugins: [], + errors: [], + }); + expect(payload.hint).toContain('not web pages'); + expect(payload.hint).toContain('webcmd web fetch --url "https://html.duckduckgo.com/html/?q=tls%20fingerprint"'); + }); + it('installs hosted marketplace plugins without fetching the manifest', async () => { const requests: string[] = []; const stdout = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index c3f44b71..c4f92576 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -32,6 +32,7 @@ import { browserCommandCatalog } from '../browser/command-catalog.js'; import { loadBrowserRunSource } from '../browser/run/input.js'; import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; +import { formatPluginSearchEmptyCopy, presentPluginSearch } from '../plugin-search-presentation.js'; import { missingPluginGuidance } from '../discovery.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; @@ -288,21 +289,26 @@ async function dispatchHosted( } if (parsed.command === 'search') { const result = await client.searchMarketplacePlugins(parsed.query); + const presented = presentPluginSearch(result, parsed.query); if (parsed.format === 'json') { - await renderOutput(result, { fmt: 'json', stdout }); + await renderOutput(presented, { fmt: 'json', stdout }); } else { for (const error of result.errors) await writeToStream(stderr, `Warning: ${error.sourceId}: ${error.message}\n`); - await renderOutput(result.plugins.map(plugin => ({ - ...plugin, - excludedCommands: plugin.excludedCommands.join(','), - })), { - fmt: parsed.format, - fmtExplicit: parsed.formatExplicit, - columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd', 'availability', 'excludedCommands'], - title: `${CLI_COMMAND}/plugin-search`, - source: `${CLI_COMMAND} plugin search`, - stdout, - }); + if (presented.total === 0) { + await writeToStream(stdout, `${formatPluginSearchEmptyCopy(presented.query)}\n`); + } else { + await renderOutput(result.plugins.map(plugin => ({ + ...plugin, + excludedCommands: plugin.excludedCommands.join(','), + })), { + fmt: parsed.format, + fmtExplicit: parsed.formatExplicit, + columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd', 'availability', 'excludedCommands'], + title: `${CLI_COMMAND}/plugin-search`, + source: `${CLI_COMMAND} plugin search`, + stdout, + }); + } } return; } diff --git a/src/plugin-search-presentation.test.ts b/src/plugin-search-presentation.test.ts new file mode 100644 index 00000000..9596ad34 --- /dev/null +++ b/src/plugin-search-presentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + formatPluginSearchEmptyCopy, + PLUGIN_SEARCH_KIND, + PLUGIN_SEARCH_SCOPE, + presentPluginSearch, +} from './plugin-search-presentation.js'; + +describe('plugin search presentation', () => { + it('labels every result as plugin-catalog and keeps hits additive', () => { + const presented = presentPluginSearch({ + plugins: [{ name: 'pypi', installSource: 'github:agentrhq/webcmd/pypi' }], + errors: [], + }, 'pypi'); + + expect(presented).toEqual({ + kind: PLUGIN_SEARCH_KIND, + query: 'pypi', + total: 1, + scope: PLUGIN_SEARCH_SCOPE, + plugins: [{ name: 'pypi', installSource: 'github:agentrhq/webcmd/pypi' }], + errors: [], + }); + expect(presented.hint).toBeUndefined(); + }); + + it('treats an empty research query as a catalog miss with a web-fetch next command', () => { + const presented = presentPluginSearch({ plugins: [], errors: [] }, 'tls fingerprint'); + + expect(presented).toMatchObject({ + kind: PLUGIN_SEARCH_KIND, + query: 'tls fingerprint', + total: 0, + plugins: [], + errors: [], + }); + expect(presented.hint).toContain('not web pages'); + expect(presented.hint).toContain('webcmd web fetch --url "https://html.duckduckgo.com/html/?q=tls%20fingerprint"'); + }); + + it('prints table copy that cannot be read as a failed web search', () => { + expect(formatPluginSearchEmptyCopy('ja3 tls fingerprint')).toBe([ + 'No marketplace plugins matched "ja3 tls fingerprint". This command searches the plugin catalog, not the web.', + 'webcmd plugin search finds plugins to install, not web pages. For web research: webcmd web fetch --url "https://html.duckduckgo.com/html/?q=ja3%20tls%20fingerprint"', + ].join('\n')); + }); +}); diff --git a/src/plugin-search-presentation.ts b/src/plugin-search-presentation.ts new file mode 100644 index 00000000..32a739c3 --- /dev/null +++ b/src/plugin-search-presentation.ts @@ -0,0 +1,44 @@ +import { CLI_COMMAND } from './brand.js'; + +export const PLUGIN_SEARCH_KIND = 'plugin-catalog' as const; +export const PLUGIN_SEARCH_SCOPE = 'installable marketplace plugins matched against name and description'; + +export interface PluginSearchPresentation { + kind: typeof PLUGIN_SEARCH_KIND; + query: string | null; + total: number; + scope: string; + plugins: TPlugin[]; + errors: TError[]; + hint?: string; +} + +export function presentPluginSearch( + result: { plugins: TPlugin[]; errors: TError[] }, + query?: string, +): PluginSearchPresentation { + const normalizedQuery = query?.trim() || null; + const presented: PluginSearchPresentation = { + kind: PLUGIN_SEARCH_KIND, + query: normalizedQuery, + total: result.plugins.length, + scope: PLUGIN_SEARCH_SCOPE, + plugins: result.plugins, + errors: result.errors, + }; + if (presented.total === 0) presented.hint = pluginSearchWebResearchHint(normalizedQuery); + return presented; +} + +export function formatPluginSearchEmptyCopy(query: string | null): string { + const matched = query ? `"${query}"` : 'the catalog'; + return [ + `No marketplace plugins matched ${matched}. This command searches the plugin catalog, not the web.`, + pluginSearchWebResearchHint(query), + ].join('\n'); +} + +export function pluginSearchWebResearchHint(query: string | null): string { + const encoded = query ? encodeURIComponent(query) : ''; + return `${CLI_COMMAND} plugin search finds plugins to install, not web pages. For web research: ${CLI_COMMAND} web fetch --url "https://html.duckduckgo.com/html/?q=${encoded}"`; +}