feat(cli): improve search command (#10886)

- Search across multiple registries and make the registry argument
  optional: omit it to search every registry configured in components.json
  (builtins like @shadcn excluded). Without a components.json or configured
  registries, a clear usage error is printed.
- Add a --type filter (accepts "ui" or "registry:ui", comma-separated)
  with validation against the known item types.
- Fetch registries concurrently with a capped worker pool, preserving
  result order.
- Tolerate per-registry failures when searching all configured registries
  (reported in a structured `errors` field); exit non-zero when every
  registry fails. Usage errors print directly instead of routing through
  handleError.
- MCP parity: optional registries (search-all), a `types` filter, and type
  validation across the search/list/examples tools.
- Keep the public registry surface to `searchRegistries` and make it
  self-contained (clears its own context, useCache defaults to false).
- Consolidate search formatting into registry/search, add the `errors`
  field to searchResultsSchema, and update the skill docs.
This commit is contained in:
shadcn
2026-06-08 17:46:00 +04:00
committed by GitHub
parent a721cc08e5
commit 05eb2b968b
12 changed files with 1388 additions and 93 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
improve search command

View File

@@ -1,4 +1,7 @@
import { searchRegistries } from "@/src/registry/search"
import { getConfig } from "@/src/utils/get-config"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import fsExtra from "fs-extra"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { search } from "./search"
@@ -35,6 +38,30 @@ const baseConfig = {
},
}
const mockResults = {
pagination: {
total: 2,
offset: 0,
limit: 100,
hasMore: false,
},
items: [
{
name: "button",
type: "registry:ui",
description: "A button component",
registry: "@shadcn",
addCommandArgument: "@shadcn/button",
},
{
name: "card",
type: "registry:ui",
registry: "@shadcn",
addCommandArgument: "@shadcn/card",
},
],
}
vi.mock("fs-extra", () => ({
default: {
existsSync: vi.fn(() => false),
@@ -62,8 +89,11 @@ vi.mock("@/src/registry/validator", () => ({
validateRegistryConfigForItems: vi.fn(),
}))
vi.mock("@/src/registry/search", () => ({
searchRegistries: vi.fn(() => []),
// Stub searchRegistries but keep the real printSearchResults (both now live
// in @/src/registry/search) so the human-readable output is exercised.
vi.mock("@/src/registry/search", async (importActual) => ({
...(await importActual<typeof import("@/src/registry/search")>()),
searchRegistries: vi.fn(() => mockResults),
}))
vi.mock("@/src/registry/context", () => ({
@@ -112,6 +142,185 @@ describe("search command", () => {
log.mockRestore()
exit.mockRestore()
})
it("prints human-readable output by default", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
expect(searchRegistries).toHaveBeenCalled()
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Found 2 items in @shadcn")
)
expect(log).toHaveBeenCalledWith(expect.stringContaining("@shadcn/button"))
expect(log).not.toHaveBeenCalledWith(
expect.stringContaining('"pagination"')
)
log.mockRestore()
exit.mockRestore()
})
it("prints JSON output with --json", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project", "--json"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
expect(log).toHaveBeenCalledWith(JSON.stringify(mockResults, null, 2))
log.mockRestore()
exit.mockRestore()
})
it("requires a registry when no components.json is present", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
// fs-extra.existsSync is mocked to return false (no components.json).
// This is a usage error, so it prints a message and exits 1 directly
// instead of routing through handleError.
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Provide a registry or namespace to search")
)
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("requires a registry when components.json has no registries", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
// components.json present but with no configured registries (only the
// builtin @shadcn, which is excluded from "search all").
vi.mocked(getConfig).mockReturnValueOnce({ ...baseConfig } as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(
expect.stringContaining("No registries are configured")
)
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("errors on an unknown --type", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(
["@shadcn", "--type", "bogus", "--cwd", "/tmp/test-project"],
{
from: "user",
}
)
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown type"))
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("searches all configured registries when none are provided", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
// readJson returns a raw (unresolved) components.json shape.
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
vi.mocked(getConfig).mockReturnValueOnce({
...baseConfig,
registries: {
"@acme": "https://acme.com/{name}.json",
"@internal": "https://internal.com/{name}.json",
},
} as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
// No explicit namespace args, so nothing to discover.
expect(ensureRegistriesInConfig).toHaveBeenCalledWith(
[],
expect.any(Object),
expect.any(Object)
)
// Only the configured registries are searched (builtin @shadcn is
// excluded), and per-registry failures are tolerated.
expect(searchRegistries).toHaveBeenCalledWith(
["@acme", "@internal"],
expect.objectContaining({ continueOnError: true })
)
log.mockRestore()
exit.mockRestore()
})
it("exits non-zero when every registry fails in search-all", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
vi.mocked(getConfig).mockReturnValueOnce({
...baseConfig,
registries: {
"@acme": "https://acme.com/{name}.json",
"@internal": "https://internal.com/{name}.json",
},
} as never)
// Both configured registries failed to load.
vi.mocked(searchRegistries).mockReturnValueOnce({
pagination: { total: 0, offset: 0, limit: 0, hasMore: false },
items: [],
errors: [
{ registry: "@acme", message: "boom" },
{ registry: "@internal", message: "boom" },
],
} as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
log.mockRestore()
exit.mockRestore()
})
})
function mockProcessExit() {

View File

@@ -1,12 +1,20 @@
import path from "path"
import { configWithDefaults } from "@/src/registry/config"
import { clearRegistryContext } from "@/src/registry/context"
import { searchRegistries } from "@/src/registry/search"
import {
findUnknownSearchTypes,
printSearchResults,
resolveSearchRegistries,
SEARCHABLE_TYPES,
searchRegistries,
} from "@/src/registry/search"
import { validateRegistryConfigForItems } from "@/src/registry/validator"
import { rawConfigSchema } from "@/src/schema"
import { loadEnvFiles } from "@/src/utils/env-loader"
import { createConfig, getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { Command } from "commander"
import fsExtra from "fs-extra"
@@ -15,6 +23,7 @@ import { z } from "zod"
const searchOptionsSchema = z.object({
cwd: z.string(),
query: z.string().optional(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
@@ -27,8 +36,8 @@ export const search = new Command()
.alias("list")
.description("search items from registries")
.argument(
"<registries...>",
"the registry addresses to search. Supports namespaces, GitHub sources and URLs."
"[registries...]",
"the registry addresses to search. Supports namespaces, GitHub sources and URLs. When omitted, searches all registries configured in components.json."
)
.option(
"-c, --cwd <cwd>",
@@ -37,20 +46,44 @@ export const search = new Command()
)
.option("-q, --query <query>", "query string")
.option(
"-l, --limit <number>",
"maximum number of items to display per registry",
"100"
"-t, --type <type>",
"filter by item type, e.g. ui, block, hook. Comma-separated for multiple."
)
.option("-l, --limit <number>", "maximum number of items to display", "100")
.option("-o, --offset <number>", "number of items to skip", "0")
.option("--json", "output as JSON.", false)
.action(async (registries: string[], opts) => {
try {
const options = searchOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
query: opts.query,
types: opts.type
? opts.type
.split(",")
.map((type: string) => type.trim())
.filter(Boolean)
: undefined,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
})
// Validate type filters up front so an unknown type fails clearly
// instead of silently returning no results.
if (options.types?.length) {
const unknownTypes = findUnknownSearchTypes(options.types)
if (unknownTypes.length > 0) {
logger.break()
logger.error(
`Unknown ${unknownTypes.length === 1 ? "type" : "types"}: ${unknownTypes
.map((type) => highlighter.info(type))
.join(", ")}.`
)
logger.error(`Valid types: ${SEARCHABLE_TYPES.join(", ")}.`)
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
// Start with a shadow config to support partial components.json.
@@ -65,7 +98,8 @@ export const search = new Command()
// Check if there's a components.json file (partial or complete).
const componentsJsonPath = path.resolve(options.cwd, "components.json")
if (fsExtra.existsSync(componentsJsonPath)) {
const hasComponentsJson = fsExtra.existsSync(componentsJsonPath)
if (hasComponentsJson) {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = configWithDefaults({
@@ -85,6 +119,33 @@ export const search = new Command()
// Use shadow config if getConfig fails (partial components.json).
}
// When no registry is provided, search across every registry configured
// in components.json. This only makes sense when a components.json is
// present to enumerate; otherwise there is nothing to search and we ask
// for an explicit registry/namespace argument.
const searchAllConfigured = registries.length === 0
if (searchAllConfigured && !hasComponentsJson) {
logger.break()
logger.error(
`Provide a registry or namespace to search, e.g. ${highlighter.info(
"shadcn search @shadcn"
)}.`
)
logger.break()
logger.error(
`If you have a ${highlighter.info(
"components.json"
)} with registries configured, run ${highlighter.info(
"shadcn search"
)} with no arguments to search all of them.`
)
logger.break()
process.exit(1)
}
// Only namespace registries passed explicitly need to be discovered and
// added to the config. Registries already configured in components.json
// are resolved directly from the config below.
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(
registries
@@ -100,19 +161,63 @@ export const search = new Command()
config.registries = updatedConfig.registries
}
// Validate registries early for better error messages.
validateRegistryConfigForItems(registries, config)
// When no registry is passed, "search all" resolves to every configured
// registry, excluding builtins (e.g. @shadcn).
const registriesToSearch = resolveSearchRegistries(registries, config)
// Use searchRegistries for both search and non-search cases
const results = await searchRegistries(registries as `@${string}`[], {
if (searchAllConfigured && registriesToSearch.length === 0) {
logger.break()
logger.error(
`No registries are configured in ${highlighter.info(
"components.json"
)}.`
)
logger.error(
`Provide a registry or namespace to search, e.g. ${highlighter.info(
"shadcn search @shadcn"
)}.`
)
logger.break()
process.exit(1)
}
// For explicitly requested registries we validate up front so the user
// gets a clear error (e.g. missing env vars). When searching every
// configured registry we skip strict validation and instead tolerate
// individual registry failures (see continueOnError below).
if (!searchAllConfigured) {
validateRegistryConfigForItems(registriesToSearch, config)
}
const results = await searchRegistries(registriesToSearch, {
query: options.query,
types: options.types,
limit: options.limit,
offset: options.offset,
config,
// Tolerate per-registry failures when searching every configured
// registry; failures are returned in `results.errors` so they can be
// surfaced to humans (printSearchResults) and machines (--json) alike.
continueOnError: searchAllConfigured,
})
console.log(JSON.stringify(results, null, 2))
process.exit(0)
// In search-all mode, failures are tolerated and collected. If *every*
// registry failed, the search did not succeed — exit non-zero.
const allRegistriesFailed =
searchAllConfigured &&
results.errors?.length === registriesToSearch.length
if (opts.json) {
console.log(JSON.stringify(results, null, 2))
} else {
printSearchResults(results, {
query: options.query,
types: options.types,
registries: registriesToSearch,
})
}
process.exit(allRegistriesFailed ? 1 : 0)
} catch (error) {
handleError(error)
} finally {

View File

@@ -1,5 +1,9 @@
import { getRegistryItems, searchRegistries } from "@/src/registry"
import { RegistryError } from "@/src/registry/errors"
import {
resolveSearchRegistries,
SEARCHABLE_TYPES,
} from "@/src/registry/search"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import {
CallToolRequestSchema,
@@ -10,9 +14,11 @@ import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import {
findUnknownTypesMessage,
formatItemExamples,
formatRegistryItems,
formatSearchResultsWithPagination,
formatSkippedRegistries,
getMcpConfig,
npxShadcn,
} from "./utils"
@@ -47,13 +53,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
"Array of registry names to list (e.g., ['@shadcn', '@acme']). Omit to list from every registry configured in components.json."
),
types: z
.array(z.string())
.optional()
.describe(
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
),
limit: z
.number()
.optional()
.describe("Maximum number of items to return"),
.describe(
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
),
offset: z
.number()
.optional()
@@ -69,18 +84,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
),
query: z
.string()
.describe(
"Search query string for fuzzy matching against item names and descriptions"
),
types: z
.array(z.string())
.optional()
.describe(
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
),
limit: z
.number()
.optional()
.describe("Maximum number of items to return"),
.describe(
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
),
offset: z
.number()
.optional()
@@ -110,8 +134,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
),
query: z
.string()
@@ -196,21 +221,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "search_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()),
registries: z.array(z.string()).optional(),
query: z.string(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
const args = inputSchema.parse(request.params.arguments)
const results = await searchRegistries(args.registries, {
const unknownTypesMessage = findUnknownTypesMessage(args.types)
if (unknownTypesMessage) {
return {
content: [{ type: "text", text: unknownTypesMessage }],
isError: true,
}
}
const config = await getMcpConfig(process.cwd())
// When registries are omitted, search every configured registry and
// tolerate individual failures.
const searchAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
query: args.query,
limit: args.limit,
types: args.types,
limit: args.limit ?? 100,
offset: args.offset,
config: await getMcpConfig(process.cwd()),
config,
useCache: false,
continueOnError: searchAll,
})
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) {
return {
content: [
@@ -218,9 +278,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: "text",
text: dedent`No items found matching "${
args.query
}" in registries ${args.registries.join(
}" in registries ${registries.join(
", "
)}, Try searching with a different query or registry.`,
)}, Try searching with a different query or registry.${skippedNote}`,
},
],
}
@@ -230,10 +290,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [
{
type: "text",
text: formatSearchResultsWithPagination(results, {
query: args.query,
registries: args.registries,
}),
text:
formatSearchResultsWithPagination(results, {
query: args.query,
registries,
}) + skippedNote,
},
],
}
@@ -241,28 +302,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "list_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()),
registries: z.array(z.string()).optional(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
cwd: z.string().optional(),
})
const args = inputSchema.parse(request.params.arguments)
const results = await searchRegistries(args.registries, {
limit: args.limit,
const unknownTypesMessage = findUnknownTypesMessage(args.types)
if (unknownTypesMessage) {
return {
content: [{ type: "text", text: unknownTypesMessage }],
isError: true,
}
}
const config = await getMcpConfig(process.cwd())
const listAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
types: args.types,
limit: args.limit ?? 100,
offset: args.offset,
config: await getMcpConfig(process.cwd()),
config,
useCache: false,
continueOnError: listAll,
})
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found in registries ${args.registries.join(
text: dedent`No items found in registries ${registries.join(
", "
)}.`,
)}.${skippedNote}`,
},
],
}
@@ -272,9 +366,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [
{
type: "text",
text: formatSearchResultsWithPagination(results, {
registries: args.registries,
}),
text:
formatSearchResultsWithPagination(results, {
registries,
}) + skippedNote,
},
],
}
@@ -321,16 +416,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "get_item_examples_from_registries": {
const inputSchema = z.object({
query: z.string(),
registries: z.array(z.string()),
registries: z.array(z.string()).optional(),
})
const args = inputSchema.parse(request.params.arguments)
const config = await getMcpConfig()
const results = await searchRegistries(args.registries, {
const searchAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
query: args.query,
config,
useCache: false,
continueOnError: searchAll,
})
if (results.items.length === 0) {

View File

@@ -1,4 +1,5 @@
import { getRegistriesConfig } from "@/src/registry/api"
import { findUnknownSearchTypes, SEARCHABLE_TYPES } from "@/src/registry/search"
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
import { getPackageRunner } from "@/src/utils/get-package-manager"
import { z } from "zod"
@@ -78,6 +79,41 @@ export function formatSearchResultsWithPagination(
return output
}
// Validates type filters the same way the CLI does. Returns an error message
// listing the valid types when any are unknown, or null when all are valid.
export function findUnknownTypesMessage(types?: string[]): string | null {
if (!types?.length) {
return null
}
const unknown = findUnknownSearchTypes(types)
if (unknown.length === 0) {
return null
}
return `Unknown type${
unknown.length === 1 ? "" : "s"
}: ${unknown.join(", ")}. Valid types: ${SEARCHABLE_TYPES.join(", ")}.`
}
// When searching across all configured registries, some may fail to load.
// Returns a note listing them (empty string when there were no failures).
export function formatSkippedRegistries(
results: z.infer<typeof searchResultsSchema>
) {
if (!results.errors?.length) {
return ""
}
const lines = results.errors.map(
(error) => `- ${error.registry}: ${error.message}`
)
return `\n\nSkipped ${results.errors.length} registr${
results.errors.length === 1 ? "y" : "ies"
} that failed to load:\n${lines.join("\n")}`
}
export function formatRegistryItems(
items: z.infer<typeof registryItemSchema>[]
) {

View File

@@ -287,6 +287,11 @@ export const searchResultItemSchema = z.object({
addCommandArgument: z.string(),
})
export const searchResultErrorSchema = z.object({
registry: z.string(),
message: z.string(),
})
export const searchResultsSchema = z.object({
pagination: z.object({
total: z.number(),
@@ -295,6 +300,10 @@ export const searchResultsSchema = z.object({
hasMore: z.boolean(),
}),
items: z.array(searchResultItemSchema),
// Registries that failed to load during the search. Only present when a
// search tolerates per-registry failures (see searchRegistries'
// continueOnError) and at least one registry was skipped.
errors: z.array(searchResultErrorSchema).optional(),
})
// Legacy schema for getRegistriesIndex() backward compatibility.

View File

@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from "vitest"
import { getRegistry } from "./api"
import { buildRegistryItemNameFromRegistry, searchRegistries } from "./search"
import {
buildRegistryItemNameFromRegistry,
findUnknownSearchTypes,
formatSearchResultDescription,
formatSearchResultType,
printSearchResults,
resolveSearchRegistries,
SEARCH_CONCURRENCY,
SEARCH_RESULT_DESCRIPTION_MAX_LENGTH,
SEARCHABLE_TYPES,
searchRegistries,
} from "./search"
describe("searchRegistries", () => {
it("should fetch and return registries in flat format", async () => {
@@ -152,6 +163,177 @@ describe("searchRegistries", () => {
mockGetRegistry.mockRestore()
})
it("collects errors and continues when continueOnError is set", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
}))
const mockGetRegistry = vi.mocked(getRegistry)
mockGetRegistry.mockImplementation(async (name: string) => {
if (name === "@ok") {
return {
name: "ok/registry",
homepage: "https://ok.com",
items: [
{ name: "button", type: "registry:ui", description: "A button" },
],
}
}
throw new Error(`Registry not found: ${name}`)
})
const results = await searchRegistries(["@ok", "@broken"], {
continueOnError: true,
})
// Items from the working registry are still returned.
expect(results.items).toHaveLength(1)
expect(results.items[0].name).toBe("button")
// The failing registry is recorded in errors instead of throwing.
expect(results.errors).toEqual([
{
registry: "@broken",
message: "Registry not found: @broken",
},
])
mockGetRegistry.mockRestore()
})
it("preserves argument order even when responses resolve out of order", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
}))
const mockGetRegistry = vi.mocked(getRegistry)
// @slow resolves after @fast, but its items must still come first because
// it is listed first. Guards the parallel fetch / ordered processing.
mockGetRegistry.mockImplementation(async (name: string) => {
if (name === "@slow") {
await new Promise((resolve) => setTimeout(resolve, 20))
return {
name: "slow",
homepage: "https://slow.com",
items: [{ name: "slow-item", type: "registry:ui", description: "" }],
}
}
if (name === "@fast") {
return {
name: "fast",
homepage: "https://fast.com",
items: [{ name: "fast-item", type: "registry:ui", description: "" }],
}
}
throw new Error(`Unknown registry: ${name}`)
})
const results = await searchRegistries(["@slow", "@fast"])
expect(results.items.map((item) => item.name)).toEqual([
"slow-item",
"fast-item",
])
mockGetRegistry.mockRestore()
})
it("caps how many registries are fetched concurrently", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
}))
const mockGetRegistry = vi.mocked(getRegistry)
let active = 0
let maxActive = 0
mockGetRegistry.mockImplementation(async (name: string) => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, 10))
active--
return {
name,
homepage: "https://test.com",
items: [{ name: `${name}-item`, type: "registry:ui", description: "" }],
}
})
const registries = Array.from({ length: 20 }, (_, i) => `@r${i}`)
const results = await searchRegistries(registries)
// All registries are still fetched...
expect(results.items).toHaveLength(20)
// ...but never more than the concurrency cap at once.
expect(maxActive).toBeLessThanOrEqual(SEARCH_CONCURRENCY)
mockGetRegistry.mockRestore()
})
it("filters by type (shorthand and full namespace, multiple)", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
}))
const mockGetRegistry = vi.mocked(getRegistry)
mockGetRegistry.mockImplementation(async () => ({
name: "test/registry",
homepage: "https://test.com",
items: [
{ name: "button", type: "registry:ui", description: "" },
{ name: "dashboard", type: "registry:block", description: "" },
{ name: "use-foo", type: "registry:hook", description: "" },
],
}))
// Shorthand, multiple types.
const multiple = await searchRegistries(["@test"], {
types: ["ui", "hook"],
})
expect(multiple.items.map((item) => item.name)).toEqual([
"button",
"use-foo",
])
// Full namespaced form is accepted too.
const full = await searchRegistries(["@test"], {
types: ["registry:block"],
})
expect(full.items.map((item) => item.name)).toEqual(["dashboard"])
mockGetRegistry.mockRestore()
})
it("combines a type filter with a query", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
}))
const mockGetRegistry = vi.mocked(getRegistry)
mockGetRegistry.mockImplementation(async () => ({
name: "test/registry",
homepage: "https://test.com",
items: [
{ name: "button", type: "registry:ui", description: "A button" },
{ name: "button-group", type: "registry:block", description: "" },
],
}))
const results = await searchRegistries(["@test"], {
query: "button",
types: ["ui"],
})
// Both match the query, but only the ui item survives the type filter.
expect(results.items.map((item) => item.name)).toEqual(["button"])
mockGetRegistry.mockRestore()
})
it("should return empty items when search has no matches", async () => {
vi.mock("./api", () => ({
getRegistry: vi.fn(),
@@ -653,3 +835,255 @@ describe("buildRegistryItemNameFromRegistry", () => {
expect(result).toBe(expected)
})
})
describe("formatSearchResultType", () => {
it("strips the registry prefix", () => {
expect(formatSearchResultType("registry:ui")).toBe("ui")
expect(formatSearchResultType("registry:block")).toBe("block")
})
it("returns other types unchanged", () => {
expect(formatSearchResultType("custom:type")).toBe("custom:type")
expect(formatSearchResultType(undefined)).toBe("")
})
})
describe("formatSearchResultDescription", () => {
it("returns short descriptions unchanged", () => {
expect(formatSearchResultDescription("A simple login form.")).toBe(
"A simple login form."
)
})
it("truncates long descriptions with an ellipsis", () => {
const description =
"A dashboard with sidebar, charts, data table, filters, and many other widgets for managing your application."
const formatted = formatSearchResultDescription(description)
expect(formatted.length).toBeLessThanOrEqual(
SEARCH_RESULT_DESCRIPTION_MAX_LENGTH
)
expect(formatted.endsWith("...")).toBe(true)
expect(formatted).not.toBe(description)
})
})
describe("printSearchResults", () => {
it("prints type and description inline", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
printSearchResults(
{
pagination: {
total: 2,
offset: 0,
limit: 100,
hasMore: false,
},
items: [
{
name: "button",
type: "registry:ui",
description: "A button component",
registry: "@shadcn",
addCommandArgument: "@shadcn/button",
},
{
name: "card",
type: "registry:ui",
registry: "@shadcn",
addCommandArgument: "@shadcn/card",
},
],
},
{
query: "button",
registries: ["@shadcn"],
}
)
expect(log).toHaveBeenCalledWith(
expect.stringContaining('Found 2 items matching "button" in @shadcn')
)
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Showing 1-2 of 2")
)
expect(log).toHaveBeenCalledWith(
expect.stringMatching(
/- @shadcn\/button \(ui\) — A button component\n- @shadcn\/card \(ui\)$/
)
)
log.mockRestore()
})
it("includes the type filter in the header (normalized for display)", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
printSearchResults(
{
pagination: { total: 1, offset: 0, limit: 100, hasMore: false },
items: [
{
name: "button",
type: "registry:ui",
registry: "@shadcn",
addCommandArgument: "@shadcn/button",
},
],
},
{
// Full namespaced form on input is shown as the shorthand.
types: ["registry:ui"],
registries: ["@shadcn"],
}
)
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Found 1 item of type ui in @shadcn")
)
log.mockRestore()
})
it("prints registry when searching multiple registries", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
printSearchResults(
{
pagination: {
total: 1,
offset: 0,
limit: 100,
hasMore: false,
},
items: [
{
name: "header",
type: "registry:component",
description: "A header component",
registry: "@custom",
addCommandArgument: "@custom/header",
},
],
},
{
registries: ["@shadcn", "@custom"],
}
)
expect(log).toHaveBeenCalledWith(
expect.stringMatching(
/- @custom\/header \(component\) · @custom — A header component/
)
)
log.mockRestore()
})
it("prints a warning for each skipped registry", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
printSearchResults(
{
pagination: {
total: 1,
offset: 0,
limit: 100,
hasMore: false,
},
items: [
{
name: "button",
type: "registry:ui",
registry: "@ok",
addCommandArgument: "@ok/button",
},
],
errors: [{ registry: "@broken", message: "Not found" }],
},
{
registries: ["@ok", "@broken"],
}
)
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Skipped @broken: Not found")
)
log.mockRestore()
})
it("prints a warning when no items are found", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
printSearchResults(
{
pagination: {
total: 0,
offset: 0,
limit: 100,
hasMore: false,
},
items: [],
},
{
query: "missing",
registries: ["@shadcn"],
}
)
expect(log).toHaveBeenCalledWith(
expect.stringContaining('No items found matching "missing" in @shadcn')
)
log.mockRestore()
})
})
describe("resolveSearchRegistries", () => {
it("returns explicitly provided registries unchanged", () => {
expect(
resolveSearchRegistries(["@one", "@two"], {
registries: { "@shadcn": "x/{name}.json", "@one": "y/{name}.json" },
})
).toEqual(["@one", "@two"])
})
it("returns all configured registries (excluding builtins) when none given", () => {
expect(
resolveSearchRegistries([], {
registries: {
"@shadcn": "x/{name}.json",
"@one": "y/{name}.json",
"@two": "z/{name}.json",
},
})
).toEqual(["@one", "@two"])
})
it("returns empty when none given and nothing is configured", () => {
expect(resolveSearchRegistries([], { registries: {} })).toEqual([])
expect(resolveSearchRegistries([], undefined)).toEqual([])
})
})
describe("findUnknownSearchTypes", () => {
it("accepts known types in shorthand and full form", () => {
expect(findUnknownSearchTypes(["ui", "registry:block", "HOOK"])).toEqual([])
})
it("returns the unknown types", () => {
expect(findUnknownSearchTypes(["ui", "bogus", "blok"])).toEqual([
"bogus",
"blok",
])
})
it("does not offer internal-only types", () => {
expect(SEARCHABLE_TYPES).not.toContain("example")
expect(SEARCHABLE_TYPES).not.toContain("internal")
expect(findUnknownSearchTypes(["internal"])).toEqual(["internal"])
})
})

View File

@@ -1,33 +1,136 @@
import { searchResultItemSchema, searchResultsSchema } from "@/src/schema"
import {
registryItemTypeSchema,
searchResultErrorSchema,
searchResultItemSchema,
searchResultsSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fuzzysort from "fuzzysort"
import { z } from "zod"
import { resolveGitHubRegistrySource } from "./address"
import { getRegistry } from "./api"
import { BUILTIN_REGISTRIES } from "./constants"
import { clearRegistryContext } from "./context"
// Resolves which registries a search should target. When none are provided
// explicitly, returns every registry configured in the project, excluding
// builtin registries (e.g. @shadcn) — "search all" means the registries the
// user actually configured. Shared by the CLI command and the MCP server so
// both resolve "search all" the same way.
export function resolveSearchRegistries(
registries: string[],
config?: Partial<Config>
): string[] {
if (registries.length > 0) {
return registries
}
return Object.keys(config?.registries ?? {}).filter(
(registry) => !(registry in BUILTIN_REGISTRIES)
)
}
// Cap how many registries we fetch at once so searching many configured
// registries does not open an unbounded number of connections.
export const SEARCH_CONCURRENCY = 8
// Like Promise.allSettled, but runs at most `limit` tasks at a time and
// preserves input order in the returned results.
async function mapSettledWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<PromiseSettledResult<R>[]> {
const results: PromiseSettledResult<R>[] = new Array(items.length)
let cursor = 0
async function worker() {
while (cursor < items.length) {
const index = cursor++
try {
results[index] = { status: "fulfilled", value: await fn(items[index]) }
} catch (reason) {
results[index] = { status: "rejected", reason }
}
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, () =>
worker()
)
await Promise.all(workers)
return results
}
export async function searchRegistries(
registries: string[],
options?: {
query?: string
types?: string[]
limit?: number
offset?: number
config?: Partial<Config>
useCache?: boolean
// When true, a registry that fails to load is skipped (and recorded in the
// returned `errors`) instead of throwing. Use this when searching across
// many registries (e.g. all configured registries) so one broken registry
// does not abort the entire search.
continueOnError?: boolean
}
) {
const { query, limit, offset, config, useCache } = options || {}
const {
query,
types,
limit,
offset,
config,
useCache = false,
continueOnError,
} = options || {}
// Start from a clean slate so this call does not inherit registry header
// context from a previous operation. Matches getRegistryItems/resolve.
clearRegistryContext()
let allItems: z.infer<typeof searchResultItemSchema>[] = []
const errors: z.infer<typeof searchResultErrorSchema>[] = []
for (const registry of registries) {
const registryData = await getRegistry(registry, { config, useCache })
// Fetch registries concurrently (capped), then process the results in the
// original order so the output is deterministic regardless of which
// responses land first. This matters most when searching many registries.
const outcomes = await mapSettledWithConcurrency(
registries,
SEARCH_CONCURRENCY,
(registry) => getRegistry(registry, { config, useCache })
)
const itemsWithRegistry = (registryData.items || []).map((item) => ({
for (let index = 0; index < registries.length; index++) {
const registry = registries[index]
const outcome = outcomes[index]
if (outcome.status === "rejected") {
if (!continueOnError) {
throw outcome.reason
}
errors.push({
registry,
message:
outcome.reason instanceof Error
? outcome.reason.message
: String(outcome.reason),
})
continue
}
const itemsWithRegistry = (outcome.value.items || []).map((item) => ({
name: item.name,
type: item.type,
description: item.description,
registry: registry,
registry,
addCommandArgument: buildRegistryItemNameFromRegistry(
item.name,
registry
@@ -37,6 +140,19 @@ export async function searchRegistries(
allItems = allItems.concat(itemsWithRegistry)
}
// Filter by type before the fuzzy query. Accepts both shorthand ("ui") and
// the full namespaced form ("registry:ui"), case-insensitively.
if (types?.length) {
const wantedTypes = new Set(
types.map((type) => formatSearchResultType(type).toLowerCase())
)
allItems = allItems.filter(
(item) =>
item.type &&
wantedTypes.has(formatSearchResultType(item.type).toLowerCase())
)
}
if (query) {
allItems = searchItems(allItems, {
query,
@@ -57,6 +173,9 @@ export async function searchRegistries(
hasMore: paginationOffset + paginationLimit < totalItems,
},
items: allItems.slice(paginationOffset, paginationOffset + paginationLimit),
// Only surface errors when present so consumers parsing successful
// searches see the same shape as before.
...(errors.length > 0 ? { errors } : {}),
}
return searchResultsSchema.parse(result)
@@ -179,3 +298,144 @@ export function buildRegistryItemNameFromRegistry(
return hostPart + updatedPath + updatedQuery
}
export const SEARCH_RESULT_DESCRIPTION_MAX_LENGTH = 80
export function formatSearchResultType(type?: string) {
if (!type) {
return ""
}
return type.startsWith("registry:") ? type.slice("registry:".length) : type
}
// Internal-only types that should not be offered as a --type filter.
const INTERNAL_TYPES = ["registry:example", "registry:internal"]
// The item types accepted by the --type filter, in shorthand form (e.g. "ui").
export const SEARCHABLE_TYPES = registryItemTypeSchema.options
.filter((type) => !INTERNAL_TYPES.includes(type))
.map((type) => formatSearchResultType(type))
// Returns the provided types that are not valid searchable types. Accepts both
// shorthand ("ui") and the full namespaced form ("registry:ui").
export function findUnknownSearchTypes(types: string[]): string[] {
const valid = new Set(SEARCHABLE_TYPES.map((type) => type.toLowerCase()))
return types.filter(
(type) => !valid.has(formatSearchResultType(type).toLowerCase())
)
}
export function formatSearchResultDescription(
description: string,
maxLength = SEARCH_RESULT_DESCRIPTION_MAX_LENGTH
) {
const normalized = description.trim().replace(/\s+/g, " ")
if (normalized.length <= maxLength) {
return normalized
}
const truncated = normalized.slice(0, maxLength - 3).trimEnd()
const lastSpace = truncated.lastIndexOf(" ")
const base =
lastSpace > maxLength * 0.6 ? truncated.slice(0, lastSpace) : truncated
return `${base.trimEnd()}...`
}
function formatSearchResultItem(
item: z.infer<typeof searchResultsSchema>["items"][number],
options: {
showRegistry: boolean
}
) {
const name = item.addCommandArgument ?? item.name
const type = formatSearchResultType(item.type)
const typeSuffix = type ? ` (${type})` : ""
const registrySuffix =
options.showRegistry && item.registry ? ` · ${item.registry}` : ""
const descriptionSuffix = item.description
? `${formatSearchResultDescription(item.description)}`
: ""
return `- ${highlighter.info(name)}${typeSuffix}${registrySuffix}${descriptionSuffix}`
}
// Describes what was searched, e.g. ` of type ui matching "button" in @one`.
// Shared by the results header and the empty-state message so they stay in
// sync. Types are normalized for display ("registry:ui" → "ui") to match how
// types are shown in the results themselves.
function formatSearchScope(options: {
query?: string
types?: string[]
registries: string[]
}) {
const { query, types, registries } = options
let scope = ""
if (types?.length) {
scope += ` of type ${types
.map((type) => formatSearchResultType(type))
.join(", ")}`
}
if (query) {
scope += ` matching ${highlighter.info(`"${query}"`)}`
}
if (registries.length > 0) {
scope += ` in ${registries.join(", ")}`
}
return scope
}
export function printSearchResults(
results: z.infer<typeof searchResultsSchema>,
options: {
query?: string
types?: string[]
registries: string[]
}
) {
const { pagination, items, errors } = results
const showRegistry = options.registries.length > 1
// Surface any registries that were skipped during the search so users know
// the results may be incomplete.
if (errors?.length) {
for (const { registry, message } of errors) {
logger.warn(`Skipped ${registry}: ${message}`)
}
logger.break()
}
if (items.length === 0) {
logger.warn(`No items found${formatSearchScope(options)}.`)
return
}
const itemCount = `${pagination.total} item${
pagination.total === 1 ? "" : "s"
}`
logger.info(`Found ${itemCount}${formatSearchScope(options)}`)
const start = pagination.offset + 1
const end = Math.min(pagination.offset + pagination.limit, pagination.total)
logger.log(`Showing ${start}-${end} of ${pagination.total}`)
logger.break()
logger.log(
items
.map((item) => formatSearchResultItem(item, { showRegistry }))
.join("\n")
)
if (pagination.hasMore) {
logger.break()
logger.log(
`More items available. Use ${highlighter.info(
`--offset ${pagination.offset + pagination.limit}`
)} to see the next page.`
)
}
}

View File

@@ -3,6 +3,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
import { configureRegistries, createRegistryServer } from "../utils/registry"
async function runSearch(fixturePath: string, args: string[]) {
return npxShadcn(fixturePath, [...args, "--json"])
}
const registryShadcn = await createRegistryServer(
[
{
@@ -186,7 +190,7 @@ describe("shadcn search", () => {
await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
const output = await runSearch(fixturePath, ["search", "@shadcn"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -225,7 +229,7 @@ describe("shadcn search", () => {
"@two": "http://localhost:9182/registry/{name}",
})
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"@one",
@@ -249,7 +253,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@one"])
const output = await runSearch(fixturePath, ["search", "@one"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -290,7 +294,7 @@ describe("shadcn search", () => {
},
})
const output = await npxShadcn(fixturePath, ["search", "@two"])
const output = await runSearch(fixturePath, ["search", "@two"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -337,7 +341,7 @@ describe("shadcn search", () => {
try {
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
const output = await npxShadcn(fixturePath, ["search", "@two"])
const output = await runSearch(fixturePath, ["search", "@two"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -379,7 +383,7 @@ describe("shadcn search", () => {
await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
const output = await runSearch(fixturePath, ["search", "@shadcn"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -419,7 +423,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@one"])
const output = await runSearch(fixturePath, ["search", "@one"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -522,7 +526,7 @@ describe("shadcn search", () => {
"@two": "http://localhost:9182/registry/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -549,7 +553,7 @@ describe("shadcn search", () => {
})
// List from both registries
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items")
@@ -577,7 +581,7 @@ describe("shadcn search", () => {
})
// Search for "button" with pagination
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -605,7 +609,7 @@ describe("shadcn search", () => {
"@shadcn": "http://localhost:9180/r/{name}",
})
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -644,7 +648,7 @@ describe("shadcn search", () => {
// Test button typos
for (const typo of typos.slice(0, 4)) {
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -658,7 +662,7 @@ describe("shadcn search", () => {
}
// Test dialog typo
const dialogOutput = await npxShadcn(fixturePath, [
const dialogOutput = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -671,7 +675,7 @@ describe("shadcn search", () => {
).toBe(true)
// Test alert typo
const alertOutput = await npxShadcn(fixturePath, [
const alertOutput = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -699,7 +703,7 @@ describe("shadcn search", () => {
]
for (const { query, expected } of partialQueries) {
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -720,19 +724,19 @@ describe("shadcn search", () => {
})
// Search with different cases
const output1 = await npxShadcn(fixturePath, [
const output1 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
"BUTTON",
])
const output2 = await npxShadcn(fixturePath, [
const output2 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
"button",
])
const output3 = await npxShadcn(fixturePath, [
const output3 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -757,7 +761,7 @@ describe("shadcn search", () => {
})
// Test searching for components with hyphens
const output1 = await npxShadcn(fixturePath, [
const output1 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -769,7 +773,7 @@ describe("shadcn search", () => {
).toBe(true)
// Test searching with just the hyphen part
const output2 = await npxShadcn(fixturePath, [
const output2 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -781,7 +785,7 @@ describe("shadcn search", () => {
).toBe(true)
// Test with spaces (should still work)
const output3 = await npxShadcn(fixturePath, [
const output3 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -800,7 +804,7 @@ describe("shadcn search", () => {
})
// Search for "bar" which should match "bar" exactly
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@one",
"--query",
@@ -821,7 +825,7 @@ describe("shadcn search", () => {
})
// Use 'search' instead of 'list'
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"--query",
@@ -846,7 +850,7 @@ describe("shadcn search", () => {
})
// Test with limit 0 - should return all items
const output1 = await npxShadcn(fixturePath, [
const output1 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--limit",
@@ -856,7 +860,7 @@ describe("shadcn search", () => {
expect(parsed1.items.length).toBeGreaterThan(0)
// Test with very large limit
const output2 = await npxShadcn(fixturePath, [
const output2 = await runSearch(fixturePath, [
"search",
"@shadcn",
"--limit",
@@ -875,7 +879,7 @@ describe("shadcn search", () => {
})
// Search across multiple registries with pagination
const output = await npxShadcn(fixturePath, [
const output = await runSearch(fixturePath, [
"search",
"@shadcn",
"@one",
@@ -900,7 +904,7 @@ describe("shadcn search", () => {
})
// First page
const output1 = await npxShadcn(fixturePath, [
const output1 = await runSearch(fixturePath, [
"search",
"@large",
"--limit",
@@ -920,7 +924,7 @@ describe("shadcn search", () => {
})
// Middle page
const output2 = await npxShadcn(fixturePath, [
const output2 = await runSearch(fixturePath, [
"search",
"@large",
"--limit",
@@ -935,7 +939,7 @@ describe("shadcn search", () => {
expect(parsed2.pagination.hasMore).toBe(true)
// Last page (partial)
const output3 = await npxShadcn(fixturePath, [
const output3 = await runSearch(fixturePath, [
"search",
"@large",
"--limit",
@@ -957,7 +961,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@one"])
const output = await runSearch(fixturePath, ["search", "@one"])
const parsed = JSON.parse(output.stdout)
// Check that we only get name, type, description, registry, and addCommand fields
@@ -986,7 +990,7 @@ describe("shadcn search", () => {
"@two": "http://localhost:9182/registry/{name}",
})
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
const parsed = JSON.parse(output.stdout)
// Check @one registry items have correct addCommand
@@ -1001,4 +1005,115 @@ describe("shadcn search", () => {
)
expect(itemItem.addCommandArgument).toBe("@two/item")
})
it("filters results by type (shorthand and full namespace)", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}",
"@one": "http://localhost:9181/r/{name}",
})
// @shadcn items are registry:ui, @one items are registry:component.
const uiOutput = await runSearch(fixturePath, [
"search",
"@shadcn",
"@one",
"--type",
"ui",
])
const ui = JSON.parse(uiOutput.stdout)
expect(ui.items.length).toBeGreaterThan(0)
expect(ui.items.every((item: any) => item.type === "registry:ui")).toBe(
true
)
// Full namespaced form is accepted too.
const componentOutput = await runSearch(fixturePath, [
"search",
"@shadcn",
"@one",
"--type",
"registry:component",
])
const component = JSON.parse(componentOutput.stdout)
expect(component.items.length).toBeGreaterThan(0)
expect(
component.items.every((item: any) => item.type === "registry:component")
).toBe(true)
})
it("errors on an unknown --type", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}",
})
const output = await npxShadcn(fixturePath, [
"search",
"@shadcn",
"--type",
"bogus",
])
expect(output.stdout).toContain("Unknown type")
expect(output.stdout).toContain("bogus")
})
it("searches all configured registries when no registry is provided", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}",
"@one": "http://localhost:9181/r/{name}",
"@two": "http://localhost:9182/registry/{name}",
})
const output = await runSearch(fixturePath, ["search"])
const parsed = JSON.parse(output.stdout)
const registries = parsed.items.map((item: any) => item.registry)
// Configured registries are searched...
expect(registries).toContain("@one")
expect(registries).toContain("@two")
// ...but the builtin @shadcn is excluded from "search all".
expect(registries).not.toContain("@shadcn")
})
it("exits non-zero when every registry fails under search-all", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
// Both registries point at an auth-protected endpoint with no credentials,
// so every registry deterministically fails to load (401). Using a running
// server avoids depending on a port being free, which races with other
// test files under vitest's concurrent file execution.
await configureRegistries(fixturePath, {
"@locked1": "http://localhost:9182/registry/bearer/{name}",
"@locked2": "http://localhost:9182/registry/bearer/{name}",
})
const output = await runSearch(fixturePath, ["search"])
expect(output.exitCode).not.toBe(0)
const parsed = JSON.parse(output.stdout)
expect(parsed.items).toHaveLength(0)
expect(parsed.errors).toHaveLength(2)
})
it("skips registries that fail to load when searching all and reports them", async () => {
const fixturePath = await createFixtureTestDirectory("next-app-init")
await configureRegistries(fixturePath, {
"@one": "http://localhost:9181/r/{name}", // works
"@locked": "http://localhost:9182/registry/bearer/{name}", // 401, no creds
})
const output = await runSearch(fixturePath, ["search"])
const parsed = JSON.parse(output.stdout)
// The working registry still returns items.
expect(parsed.items.some((item: any) => item.registry === "@one")).toBe(
true
)
// The failing registry is reported in errors instead of aborting.
expect(parsed.errors).toEqual(
expect.arrayContaining([expect.objectContaining({ registry: "@locked" })])
)
})
})

View File

@@ -240,6 +240,8 @@ npx shadcn@latest add owner/repo/item --dry-run
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
npx shadcn@latest search owner/repo -q "login"
npx shadcn@latest search # all configured registries
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select

View File

@@ -130,19 +130,22 @@ See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the fu
### `search` — Search registries
```bash
npx shadcn@latest search <registries...> [options]
npx shadcn@latest search [registries...] [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
and registry catalog URLs. Without `-q`, lists all items.
and registry catalog URLs. Without `-q`, lists all items. When no registries are
passed, searches every registry configured in `components.json`.
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
| `--limit <number>` | `-l` | Max items to display | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--json` | | Output as JSON | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details

View File

@@ -37,16 +37,19 @@ Returns registry names from `components.json`. Errors if no `components.json` ex
Lists all items from one or more registries. Registries can be configured
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
registry catalog URLs.
registry catalog URLs. Omit `registries` to list from every registry configured
in `components.json`.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries. Registries can be configured namespaces, public
GitHub sources, or registry catalog URLs.
GitHub sources, or registry catalog URLs. Omit `registries` to search every
registry configured in `components.json` — e.g. "find me a hero" across all
configured registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
### `shadcn:view_items_in_registries`
@@ -57,9 +60,10 @@ View item details including full file contents.
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
Find usage examples and demos with source code. Omit `registries` to search
every registry configured in `components.json`.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`