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
134 changes: 97 additions & 37 deletions src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,30 @@ export function formatSkillSearchSummary(count: number, query: string, truncated
return `Showing ${count} skill${count !== 1 ? 's' : ''} matching "${query}"${truncated ? ' (truncated)' : ''}`;
}

export function formatSkillSearchHint(item: Pick<SkillSearchItem, 'stars' | 'description'>): string {
return [
item.stars > 0 ? `★ ${item.stars}` : '',
item.description ?? '',
].filter(Boolean).join(' ');
}

export function collectSelectedSkillSearchRepos(
items: Pick<SkillSearchItem, 'path' | 'repo'>[],
selectedPaths: string[],
): string[] {
const selectedSet = new Set(selectedPaths);
const repos: string[] = [];
const seenRepos = new Set<string>();

for (const item of items) {
if (!selectedSet.has(item.path) || seenRepos.has(item.repo)) continue;
seenRepos.add(item.repo);
repos.push(item.repo);
}

return repos;
}

/** Print results in gh-compatible tabular format: repo, skillName, description, stars. */
function printSearchResults(items: SkillSearchItem[], query: string, truncated: boolean): void {
console.log(`\n${formatSkillSearchSummary(items.length, query, truncated)}\n`);
Expand All @@ -1389,16 +1413,26 @@ function printSearchResults(items: SkillSearchItem[], query: string, truncated:
* Interactive install flow for a selected plugin from search results.
* Returns true if plugin was installed.
*/
async function installFromSearch(repo: string): Promise<boolean> {
async function installFromSearch(repos: string[]): Promise<boolean> {
const p = await import('@clack/prompts');

const workspacePath = process.cwd();
const isInstalledProject = hasProjectConfig(workspacePath) ? await hasPlugin(repo, workspacePath) : false;
const isInstalledUser = await hasUserPlugin(repo);
const installableRepos: string[] = [];

if (isInstalledProject || isInstalledUser) {
const scopeLabel = isInstalledUser ? 'user' : 'project';
p.log.info(`Plugin ${chalk.bold(repo)} is already installed (${scopeLabel} scope).`);
for (const repo of repos) {
const isInstalledProject = hasProjectConfig(workspacePath) ? await hasPlugin(repo, workspacePath) : false;
const isInstalledUser = await hasUserPlugin(repo);

if (isInstalledProject || isInstalledUser) {
const scopeLabel = isInstalledUser ? 'user' : 'project';
p.log.info(`Plugin ${chalk.bold(repo)} is already installed (${scopeLabel} scope).`);
continue;
}

installableRepos.push(repo);
}

if (installableRepos.length === 0) {
return false;
}

Expand All @@ -1413,34 +1447,55 @@ async function installFromSearch(repo: string): Promise<boolean> {
if (p.isCancel(scopeChoice)) return false;

const s = p.spinner();
s.start('Installing plugin...');
s.start(`Installing ${installableRepos.length === 1 ? 'plugin' : 'plugins'}...`);

try {
if (scopeChoice === 'project') {
const result = await addPlugin(repo, workspacePath);
const installedRepos: string[] = [];
const failedRepos: Array<{ repo: string; error: string }> = [];

for (const repo of installableRepos) {
const result = scopeChoice === 'project'
? await addPlugin(repo, workspacePath)
: await addUserPlugin(repo);

if (!result.success) {
s.stop('Installation failed');
p.log.error(result.error ?? 'Unknown error');
return false;
failedRepos.push({ repo, error: result.error ?? 'Unknown error' });
continue;
}
s.message('Syncing...');
const syncResult = await syncWorkspace(workspacePath);
s.stop('Installed and synced');
const lines = formatVerboseSyncLines(syncResult);
if (lines.length > 0) p.note(lines.join('\n'), `Installed: ${repo}`);
} else {
const result = await addUserPlugin(repo);
if (!result.success) {
s.stop('Installation failed');
p.log.error(result.error ?? 'Unknown error');
return false;

installedRepos.push(repo);
}

if (installedRepos.length === 0) {
s.stop('Installation failed');
for (const { repo, error } of failedRepos) {
p.log.error(`${chalk.bold(repo)}: ${error}`);
}
s.message('Syncing...');
const syncResult = await syncUserWorkspace();
s.stop('Installed and synced');
const lines = formatVerboseSyncLines(syncResult);
if (lines.length > 0) p.note(lines.join('\n'), `Installed: ${repo}`);
return false;
}

s.message('Syncing...');
const syncResult = scopeChoice === 'project'
? await syncWorkspace(workspacePath)
: await syncUserWorkspace();

s.stop(installedRepos.length === 1 ? 'Installed and synced' : 'Installed plugins and synced');

for (const { repo, error } of failedRepos) {
p.log.error(`${chalk.bold(repo)}: ${error}`);
}

const lines = formatVerboseSyncLines(syncResult);
const noteLines = installedRepos.length > 1 ? [...installedRepos, '', ...lines] : lines;
if (noteLines.length > 0) {
p.note(
noteLines.join('\n'),
installedRepos.length === 1
? `Installed: ${installedRepos[0]}`
: `Installed: ${installedRepos.length} plugins`,
);
}

return true;
} catch (err) {
s.stop('Installation failed');
Expand Down Expand Up @@ -1525,29 +1580,34 @@ const searchCmd = command({
return;
}

// Interactive mode: filter-as-you-type select with install support
const { autocomplete, isCancel, log } = await import('@clack/prompts');
// Interactive mode: filter-as-you-type multiselect with install support
const { autocompleteMultiselect, isCancel, log } = await import('@clack/prompts');

log.success(formatSkillSearchSummary(result.items.length, query, result.truncated));

const options = result.items.map((item) => ({
label: `${qualifiedName(item)} ${chalk.dim(item.repo)}`,
value: item.repo,
hint: `${item.stars > 0 ? `★${item.stars} ` : ''}${item.description ?? ''}`,
value: item.path,
hint: formatSkillSearchHint(item),
}));
options.push({ label: 'Cancel', value: '__cancel__', hint: '' });

const selected = await autocomplete({
message: 'Select a skill to install',
const selected = await autocompleteMultiselect({
message: 'Select skills to install',
options,
placeholder: 'Type to filter...',
required: false,
});

if (isCancel(selected) || selected === '__cancel__') {
if (isCancel(selected)) {
return;
}

const reposToInstall = collectSelectedSkillSearchRepos(result.items, selected as string[]);
if (reposToInstall.length === 0) {
return;
}

await installFromSearch(selected as string);
await installFromSearch(reposToInstall);
} catch (error) {
if (error instanceof SkillSearchError) {
const exitCode = error.kind === 'validation' ? 2 : 1;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/metadata/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const skillsRemoveMeta: AgentCommandMeta = {

export const skillsSearchMeta: AgentCommandMeta = {
command: 'skill search',
description: 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are sorted by star count. In TTY mode, shows a filter-as-you-type picker and offers to install the selected skill.',
description: 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are sorted by star count. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.',
whenToUse:
'To discover available skills from public GitHub repositories without leaving the CLI. Bridges "I want a skill that does X" → install.',
examples: [
Expand All @@ -54,7 +54,7 @@ export const skillsSearchMeta: AgentCommandMeta = {
'allagents skill search docs --page 2 --limit 10',
'allagents --json skill search docs --limit 5',
],
expectedOutput: 'Skills sorted by star count: repo, skill name, stars, description. In TTY mode, followed by a filter-as-you-type install prompt.',
expectedOutput: 'Skills sorted by star count: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.',
positionals: [
{ name: 'query', type: 'string', required: true, description: 'Search query (≥2 characters).' },
],
Expand Down
53 changes: 52 additions & 1 deletion tests/unit/cli/skill-search-summary.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test';
import { formatSkillSearchSummary } from '../../../src/cli/commands/plugin-skills.js';
import {
collectSelectedSkillSearchRepos,
formatSkillSearchHint,
formatSkillSearchSummary,
} from '../../../src/cli/commands/plugin-skills.js';

describe('formatSkillSearchSummary', () => {
it('uses singular skill wording for one match', () => {
Expand All @@ -20,3 +24,50 @@ describe('formatSkillSearchSummary', () => {
);
});
});

describe('formatSkillSearchHint', () => {
it('includes a space between the star icon and count', () => {
expect(formatSkillSearchHint({
stars: 1,
description: 'Locate source repositories for AI skills.',
})).toBe('★ 1 Locate source repositories for AI skills.');
});

it('omits the star section when the repo has no stars', () => {
expect(formatSkillSearchHint({
stars: 0,
description: 'Locate source repositories for AI skills.',
})).toBe('Locate source repositories for AI skills.');
});
});

describe('collectSelectedSkillSearchRepos', () => {
it('deduplicates repos when multiple selected skills come from the same plugin', () => {
expect(collectSelectedSkillSearchRepos([
{ path: 'skills/development/pr-search/SKILL.md', repo: 'WiseTechGlobal/WTG.AI.Prompts' },
{ path: 'skills/pr-search/SKILL.md', repo: 'WiseTechGlobal/PM-Workspaces' },
{ path: 'skills/other-pr-search/SKILL.md', repo: 'WiseTechGlobal/WTG.AI.Prompts' },
], [
'skills/development/pr-search/SKILL.md',
'skills/other-pr-search/SKILL.md',
'skills/pr-search/SKILL.md',
])).toEqual([
'WiseTechGlobal/WTG.AI.Prompts',
'WiseTechGlobal/PM-Workspaces',
]);
});

it('preserves search result order for the selected repos', () => {
expect(collectSelectedSkillSearchRepos([
{ path: 'skills/a/SKILL.md', repo: 'org/first' },
{ path: 'skills/b/SKILL.md', repo: 'org/second' },
{ path: 'skills/c/SKILL.md', repo: 'org/third' },
], [
'skills/c/SKILL.md',
'skills/a/SKILL.md',
])).toEqual([
'org/first',
'org/third',
]);
});
});