Skip to content

Commit bc5e8d1

Browse files
committed
feat(cli): add codingbuddy search command for plugin discovery (#1169)
Add `codingbuddy search <query>` CLI command that searches the plugin registry for matching plugins by name, description, and tags. - RegistryClient: fetches/caches index.json from GitHub (5min TTL) - Search command: formatted output with provides counts and install hint - Graceful handling of network errors and empty results - Integrate search into CLI parseArgs and main switch
1 parent 2ed7c1a commit bc5e8d1

6 files changed

Lines changed: 513 additions & 3 deletions

File tree

apps/mcp-server/src/cli/cli.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,33 @@
88
import { runInit } from './init';
99
import { bootstrap } from '../main';
1010
import { getPackageVersion } from '../shared/version.utils';
11-
import type { InitOptions, TuiOptions, InstallOptions, UninstallOptions } from './cli.types';
11+
import type {
12+
InitOptions,
13+
TuiOptions,
14+
InstallOptions,
15+
UninstallOptions,
16+
SearchOptions,
17+
} from './cli.types';
1218

1319
/**
1420
* Parsed command line arguments
1521
*/
1622
export interface ParsedArgs {
17-
command: 'init' | 'install' | 'plugins' | 'uninstall' | 'mcp' | 'tui' | 'help' | 'version';
23+
command:
24+
| 'init'
25+
| 'install'
26+
| 'plugins'
27+
| 'uninstall'
28+
| 'search'
29+
| 'mcp'
30+
| 'tui'
31+
| 'help'
32+
| 'version';
1833
options: Partial<InitOptions> &
1934
Partial<TuiOptions> &
2035
Partial<InstallOptions> &
21-
Partial<UninstallOptions>;
36+
Partial<UninstallOptions> &
37+
Partial<SearchOptions>;
2238
}
2339

2440
/**
@@ -65,6 +81,14 @@ export function parseArgs(args: string[]): ParsedArgs {
6581
return { command: 'plugins', options };
6682
}
6783

84+
if (command === 'search') {
85+
const searchQuery = args.slice(1).join(' ');
86+
return {
87+
command: 'search',
88+
options: { ...options, searchQuery },
89+
};
90+
}
91+
6892
if (command === 'uninstall') {
6993
const uninstallName = args[1];
7094
const uninstallYes = args.includes('--yes') || args.includes('-y');
@@ -107,6 +131,7 @@ CodingBuddy CLI - AI-powered project configuration generator
107131
Usage:
108132
codingbuddy init [path] [options] Initialize configuration
109133
codingbuddy install <git-url> Install a plugin from git repository
134+
codingbuddy search <query> Search plugins in the registry
110135
codingbuddy plugins List installed plugins
111136
codingbuddy uninstall <name> Uninstall a plugin
112137
codingbuddy mcp Start MCP server (stdio mode)
@@ -125,6 +150,7 @@ Examples:
125150
codingbuddy init ./my-project Initialize in specific directory
126151
codingbuddy init --force Overwrite existing config
127152
codingbuddy install github:user/repo Install a community plugin
153+
codingbuddy search nextjs Search for Next.js plugins
128154
codingbuddy plugins List all installed plugins
129155
codingbuddy uninstall my-plugin Remove a plugin
130156
codingbuddy uninstall my-plugin -y Remove without confirmation
@@ -195,6 +221,20 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
195221
break;
196222
}
197223

224+
case 'search': {
225+
const { runSearch } = await import('./plugin/search.command');
226+
if (!options.searchQuery) {
227+
process.stderr.write('Error: Missing query. Usage: codingbuddy search <query>\n');
228+
process.exitCode = 1;
229+
break;
230+
}
231+
const searchResult = await runSearch({ query: options.searchQuery });
232+
if (!searchResult.success) {
233+
process.exitCode = 1;
234+
}
235+
break;
236+
}
237+
198238
case 'install': {
199239
const { runInstall } = await import('./plugin/install.command');
200240
if (!options.installSource) {

apps/mcp-server/src/cli/cli.types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ export interface UninstallOptions {
6767
uninstallYes?: boolean;
6868
}
6969

70+
/**
71+
* Search command options (parsed from CLI args)
72+
*/
73+
export interface SearchOptions {
74+
/** Search query string */
75+
searchQuery?: string;
76+
}
77+
7078
/**
7179
* Console output levels
7280
*/
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
const mockSearch = vi.fn();
4+
const mockConsoleUtils = {
5+
log: {
6+
info: vi.fn(),
7+
success: vi.fn(),
8+
warn: vi.fn(),
9+
error: vi.fn(),
10+
step: vi.fn(),
11+
},
12+
};
13+
14+
vi.mock('../../plugin/registry-client', () => ({
15+
RegistryClient: class {
16+
search = mockSearch;
17+
},
18+
}));
19+
20+
vi.mock('../utils/console', () => ({
21+
createConsoleUtils: () => mockConsoleUtils,
22+
}));
23+
24+
import { runSearch } from './search.command';
25+
26+
describe('search.command', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
it('should call RegistryClient.search with the query', async () => {
32+
mockSearch.mockResolvedValue([]);
33+
34+
await runSearch({ query: 'nextjs' });
35+
36+
expect(mockSearch).toHaveBeenCalledWith('nextjs');
37+
});
38+
39+
it('should display formatted results when plugins found', async () => {
40+
mockSearch.mockResolvedValue([
41+
{
42+
name: 'nextjs-app-router',
43+
version: '1.0.0',
44+
description: 'Next.js App Router best practices',
45+
tags: ['nextjs', 'react', 'frontend'],
46+
provides: { agents: ['a1'], rules: ['r1', 'r2'], skills: ['s1'] },
47+
},
48+
]);
49+
50+
const result = await runSearch({ query: 'nextjs' });
51+
52+
expect(result.success).toBe(true);
53+
expect(result.count).toBe(1);
54+
// Check name/version displayed
55+
expect(mockConsoleUtils.log.step).toHaveBeenCalledWith(
56+
expect.any(String),
57+
expect.stringContaining('nextjs-app-router'),
58+
);
59+
});
60+
61+
it('should display provides counts', async () => {
62+
mockSearch.mockResolvedValue([
63+
{
64+
name: 'test-plugin',
65+
version: '1.0.0',
66+
description: 'Test',
67+
tags: ['test'],
68+
provides: { agents: ['a1'], rules: ['r1', 'r2'], skills: ['s1'] },
69+
},
70+
]);
71+
72+
await runSearch({ query: 'test' });
73+
74+
const allStepCalls = mockConsoleUtils.log.step.mock.calls.map((call: string[]) => call[1]);
75+
const providesLine = allStepCalls.find((msg: string) => msg.includes('Provides'));
76+
expect(providesLine).toContain('1 agent');
77+
expect(providesLine).toContain('2 rules');
78+
expect(providesLine).toContain('1 skill');
79+
});
80+
81+
it('should display install command for each plugin', async () => {
82+
mockSearch.mockResolvedValue([
83+
{
84+
name: 'my-plugin',
85+
version: '1.0.0',
86+
description: 'Test',
87+
tags: [],
88+
provides: {},
89+
},
90+
]);
91+
92+
await runSearch({ query: 'my' });
93+
94+
const allStepCalls = mockConsoleUtils.log.step.mock.calls.map((call: string[]) => call[1]);
95+
const installLine = allStepCalls.find((msg: string) => msg.includes('codingbuddy install'));
96+
expect(installLine).toContain('codingbuddy install my-plugin');
97+
});
98+
99+
it('should display summary with match count', async () => {
100+
mockSearch.mockResolvedValue([
101+
{
102+
name: 'a',
103+
version: '1.0.0',
104+
description: 'A',
105+
tags: [],
106+
provides: {},
107+
},
108+
{
109+
name: 'b',
110+
version: '1.0.0',
111+
description: 'B',
112+
tags: [],
113+
provides: {},
114+
},
115+
]);
116+
117+
await runSearch({ query: 'test' });
118+
119+
expect(mockConsoleUtils.log.info).toHaveBeenCalledWith(
120+
expect.stringMatching(/found 2 plugin/i),
121+
);
122+
});
123+
124+
it('should handle no results gracefully', async () => {
125+
mockSearch.mockResolvedValue([]);
126+
127+
const result = await runSearch({ query: 'nonexistent' });
128+
129+
expect(result.success).toBe(true);
130+
expect(result.count).toBe(0);
131+
expect(mockConsoleUtils.log.info).toHaveBeenCalledWith(expect.stringMatching(/no plugin/i));
132+
});
133+
134+
it('should handle network errors gracefully', async () => {
135+
mockSearch.mockRejectedValue(new Error('Network error'));
136+
137+
const result = await runSearch({ query: 'test' });
138+
139+
expect(result.success).toBe(false);
140+
expect(mockConsoleUtils.log.error).toHaveBeenCalled();
141+
});
142+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Plugin Search Command
3+
*
4+
* CLI command handler for `codingbuddy search <query>`.
5+
* Fetches the registry index and searches for matching plugins.
6+
*/
7+
8+
import {
9+
RegistryClient,
10+
RegistryPlugin,
11+
RegistryPluginProvides,
12+
} from '../../plugin/registry-client';
13+
import { createConsoleUtils } from '../utils/console';
14+
15+
// ============================================================================
16+
// Types
17+
// ============================================================================
18+
19+
export interface SearchCommandOptions {
20+
query: string;
21+
}
22+
23+
export interface SearchCommandResult {
24+
success: boolean;
25+
count: number;
26+
error?: string;
27+
}
28+
29+
// ============================================================================
30+
// Helpers
31+
// ============================================================================
32+
33+
function formatProvides(provides: RegistryPluginProvides): string {
34+
const parts: string[] = [];
35+
36+
const agents = provides.agents?.length ?? 0;
37+
const rules = provides.rules?.length ?? 0;
38+
const skills = provides.skills?.length ?? 0;
39+
const checklists = provides.checklists?.length ?? 0;
40+
41+
if (agents > 0) parts.push(`${agents} agent${agents !== 1 ? 's' : ''}`);
42+
if (rules > 0) parts.push(`${rules} rule${rules !== 1 ? 's' : ''}`);
43+
if (skills > 0) parts.push(`${skills} skill${skills !== 1 ? 's' : ''}`);
44+
if (checklists > 0) parts.push(`${checklists} checklist${checklists !== 1 ? 's' : ''}`);
45+
46+
return parts.length > 0 ? parts.join(', ') : 'no assets';
47+
}
48+
49+
function printPlugin(console: ReturnType<typeof createConsoleUtils>, plugin: RegistryPlugin): void {
50+
console.log.step(' ', `${plugin.name} (${plugin.version}) — ${plugin.description}`);
51+
if (plugin.tags.length > 0) {
52+
console.log.step(' ', ` Tags: ${plugin.tags.join(', ')}`);
53+
}
54+
console.log.step(' ', ` Provides: ${formatProvides(plugin.provides)}`);
55+
console.log.step(' ', ` Install: codingbuddy install ${plugin.name}`);
56+
}
57+
58+
// ============================================================================
59+
// Command
60+
// ============================================================================
61+
62+
export async function runSearch(options: SearchCommandOptions): Promise<SearchCommandResult> {
63+
const console = createConsoleUtils();
64+
const client = new RegistryClient();
65+
66+
try {
67+
const results = await client.search(options.query);
68+
69+
if (results.length === 0) {
70+
console.log.info(`No plugins found matching "${options.query}"`);
71+
return { success: true, count: 0 };
72+
}
73+
74+
console.log.step('🔍', `Search results for "${options.query}":\n`);
75+
76+
for (const plugin of results) {
77+
printPlugin(console, plugin);
78+
process.stdout.write('\n');
79+
}
80+
81+
console.log.info(
82+
`Found ${results.length} plugin${results.length !== 1 ? 's' : ''} matching "${options.query}"`,
83+
);
84+
85+
return { success: true, count: results.length };
86+
} catch (err) {
87+
const message = err instanceof Error ? err.message : String(err);
88+
console.log.error(`Search failed: ${message}`);
89+
return { success: false, count: 0, error: message };
90+
}
91+
}

0 commit comments

Comments
 (0)