Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/builtin-command-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
49 changes: 49 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1265,13 +1265,62 @@ 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 <fmt>');
expect(install.usage()).toBe('[options] <source>');
expect(install.description()).toBe('Install a plugin from a git repository');
expect(install.registeredArguments[0]?.description).toContain('github:user/repo/<plugin>');
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 {
Expand Down
22 changes: 14 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/hosted/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 18 additions & 12 deletions src/hosted/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
47 changes: 47 additions & 0 deletions src/plugin-search-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
44 changes: 44 additions & 0 deletions src/plugin-search-presentation.ts
Original file line number Diff line number Diff line change
@@ -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<TPlugin, TError> {
kind: typeof PLUGIN_SEARCH_KIND;
query: string | null;
total: number;
scope: string;
plugins: TPlugin[];
errors: TError[];
hint?: string;
}

export function presentPluginSearch<TPlugin, TError>(
result: { plugins: TPlugin[]; errors: TError[] },
query?: string,
): PluginSearchPresentation<TPlugin, TError> {
const normalizedQuery = query?.trim() || null;
const presented: PluginSearchPresentation<TPlugin, TError> = {
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) : '<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}"`;
}
Loading