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 { ensureRegistriesInConfig } from "@/src/utils/registries"
import fsExtra from "fs-extra"
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import { search } from "./search" 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", () => ({ vi.mock("fs-extra", () => ({
default: { default: {
existsSync: vi.fn(() => false), existsSync: vi.fn(() => false),
@@ -62,8 +89,11 @@ vi.mock("@/src/registry/validator", () => ({
validateRegistryConfigForItems: vi.fn(), validateRegistryConfigForItems: vi.fn(),
})) }))
vi.mock("@/src/registry/search", () => ({ // Stub searchRegistries but keep the real printSearchResults (both now live
searchRegistries: vi.fn(() => []), // 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", () => ({ vi.mock("@/src/registry/context", () => ({
@@ -112,6 +142,185 @@ describe("search command", () => {
log.mockRestore() log.mockRestore()
exit.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() { function mockProcessExit() {

View File

@@ -1,12 +1,20 @@
import path from "path" import path from "path"
import { configWithDefaults } from "@/src/registry/config" import { configWithDefaults } from "@/src/registry/config"
import { clearRegistryContext } from "@/src/registry/context" 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 { validateRegistryConfigForItems } from "@/src/registry/validator"
import { rawConfigSchema } from "@/src/schema" import { rawConfigSchema } from "@/src/schema"
import { loadEnvFiles } from "@/src/utils/env-loader" import { loadEnvFiles } from "@/src/utils/env-loader"
import { createConfig, getConfig } from "@/src/utils/get-config" import { createConfig, getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error" 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 { ensureRegistriesInConfig } from "@/src/utils/registries"
import { Command } from "commander" import { Command } from "commander"
import fsExtra from "fs-extra" import fsExtra from "fs-extra"
@@ -15,6 +23,7 @@ import { z } from "zod"
const searchOptionsSchema = z.object({ const searchOptionsSchema = z.object({
cwd: z.string(), cwd: z.string(),
query: z.string().optional(), query: z.string().optional(),
types: z.array(z.string()).optional(),
limit: z.number().optional(), limit: z.number().optional(),
offset: z.number().optional(), offset: z.number().optional(),
}) })
@@ -27,8 +36,8 @@ export const search = new Command()
.alias("list") .alias("list")
.description("search items from registries") .description("search items from registries")
.argument( .argument(
"<registries...>", "[registries...]",
"the registry addresses to search. Supports namespaces, GitHub sources and URLs." "the registry addresses to search. Supports namespaces, GitHub sources and URLs. When omitted, searches all registries configured in components.json."
) )
.option( .option(
"-c, --cwd <cwd>", "-c, --cwd <cwd>",
@@ -37,20 +46,44 @@ export const search = new Command()
) )
.option("-q, --query <query>", "query string") .option("-q, --query <query>", "query string")
.option( .option(
"-l, --limit <number>", "-t, --type <type>",
"maximum number of items to display per registry", "filter by item type, e.g. ui, block, hook. Comma-separated for multiple."
"100"
) )
.option("-l, --limit <number>", "maximum number of items to display", "100")
.option("-o, --offset <number>", "number of items to skip", "0") .option("-o, --offset <number>", "number of items to skip", "0")
.option("--json", "output as JSON.", false)
.action(async (registries: string[], opts) => { .action(async (registries: string[], opts) => {
try { try {
const options = searchOptionsSchema.parse({ const options = searchOptionsSchema.parse({
cwd: path.resolve(opts.cwd), cwd: path.resolve(opts.cwd),
query: opts.query, 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, limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 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) await loadEnvFiles(options.cwd)
// Start with a shadow config to support partial components.json. // 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). // Check if there's a components.json file (partial or complete).
const componentsJsonPath = path.resolve(options.cwd, "components.json") 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 existingConfig = await fsExtra.readJson(componentsJsonPath)
const partialConfig = rawConfigSchema.partial().parse(existingConfig) const partialConfig = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = configWithDefaults({ shadowConfig = configWithDefaults({
@@ -85,6 +119,33 @@ export const search = new Command()
// Use shadow config if getConfig fails (partial components.json). // 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 } = const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig( await ensureRegistriesInConfig(
registries registries
@@ -100,19 +161,63 @@ export const search = new Command()
config.registries = updatedConfig.registries config.registries = updatedConfig.registries
} }
// Validate registries early for better error messages. // When no registry is passed, "search all" resolves to every configured
validateRegistryConfigForItems(registries, config) // registry, excluding builtins (e.g. @shadcn).
const registriesToSearch = resolveSearchRegistries(registries, config)
// Use searchRegistries for both search and non-search cases if (searchAllConfigured && registriesToSearch.length === 0) {
const results = await searchRegistries(registries as `@${string}`[], { 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, query: options.query,
types: options.types,
limit: options.limit, limit: options.limit,
offset: options.offset, offset: options.offset,
config, 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)) // In search-all mode, failures are tolerated and collected. If *every*
process.exit(0) // 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) { } catch (error) {
handleError(error) handleError(error)
} finally { } finally {

View File

@@ -1,5 +1,9 @@
import { getRegistryItems, searchRegistries } from "@/src/registry" import { getRegistryItems, searchRegistries } from "@/src/registry"
import { RegistryError } from "@/src/registry/errors" import { RegistryError } from "@/src/registry/errors"
import {
resolveSearchRegistries,
SEARCHABLE_TYPES,
} from "@/src/registry/search"
import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { import {
CallToolRequestSchema, CallToolRequestSchema,
@@ -10,9 +14,11 @@ import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema" import { zodToJsonSchema } from "zod-to-json-schema"
import { import {
findUnknownTypesMessage,
formatItemExamples, formatItemExamples,
formatRegistryItems, formatRegistryItems,
formatSearchResultsWithPagination, formatSearchResultsWithPagination,
formatSkippedRegistries,
getMcpConfig, getMcpConfig,
npxShadcn, npxShadcn,
} from "./utils" } from "./utils"
@@ -47,13 +53,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({ z.object({
registries: z registries: z
.array(z.string()) .array(z.string())
.optional()
.describe( .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 limit: z
.number() .number()
.optional() .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 offset: z
.number() .number()
.optional() .optional()
@@ -69,18 +84,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({ z.object({
registries: z registries: z
.array(z.string()) .array(z.string())
.optional()
.describe( .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 query: z
.string() .string()
.describe( .describe(
"Search query string for fuzzy matching against item names and descriptions" "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 limit: z
.number() .number()
.optional() .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 offset: z
.number() .number()
.optional() .optional()
@@ -110,8 +134,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
z.object({ z.object({
registries: z registries: z
.array(z.string()) .array(z.string())
.optional()
.describe( .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 query: z
.string() .string()
@@ -196,21 +221,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "search_items_in_registries": { case "search_items_in_registries": {
const inputSchema = z.object({ const inputSchema = z.object({
registries: z.array(z.string()), registries: z.array(z.string()).optional(),
query: z.string(), query: z.string(),
types: z.array(z.string()).optional(),
limit: z.number().optional(), limit: z.number().optional(),
offset: z.number().optional(), offset: z.number().optional(),
}) })
const args = inputSchema.parse(request.params.arguments) 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, query: args.query,
limit: args.limit, types: args.types,
limit: args.limit ?? 100,
offset: args.offset, offset: args.offset,
config: await getMcpConfig(process.cwd()), config,
useCache: false, useCache: false,
continueOnError: searchAll,
}) })
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) { if (results.items.length === 0) {
return { return {
content: [ content: [
@@ -218,9 +278,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: "text", type: "text",
text: dedent`No items found matching "${ text: dedent`No items found matching "${
args.query 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: [ content: [
{ {
type: "text", type: "text",
text: formatSearchResultsWithPagination(results, { text:
query: args.query, formatSearchResultsWithPagination(results, {
registries: args.registries, query: args.query,
}), registries,
}) + skippedNote,
}, },
], ],
} }
@@ -241,28 +302,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "list_items_in_registries": { case "list_items_in_registries": {
const inputSchema = z.object({ 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(), limit: z.number().optional(),
offset: z.number().optional(), offset: z.number().optional(),
cwd: z.string().optional(), cwd: z.string().optional(),
}) })
const args = inputSchema.parse(request.params.arguments) 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, offset: args.offset,
config: await getMcpConfig(process.cwd()), config,
useCache: false, useCache: false,
continueOnError: listAll,
}) })
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) { if (results.items.length === 0) {
return { return {
content: [ content: [
{ {
type: "text", 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: [ content: [
{ {
type: "text", type: "text",
text: formatSearchResultsWithPagination(results, { text:
registries: args.registries, formatSearchResultsWithPagination(results, {
}), registries,
}) + skippedNote,
}, },
], ],
} }
@@ -321,16 +416,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "get_item_examples_from_registries": { case "get_item_examples_from_registries": {
const inputSchema = z.object({ const inputSchema = z.object({
query: z.string(), query: z.string(),
registries: z.array(z.string()), registries: z.array(z.string()).optional(),
}) })
const args = inputSchema.parse(request.params.arguments) const args = inputSchema.parse(request.params.arguments)
const config = await getMcpConfig() 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, query: args.query,
config, config,
useCache: false, useCache: false,
continueOnError: searchAll,
}) })
if (results.items.length === 0) { if (results.items.length === 0) {

View File

@@ -1,4 +1,5 @@
import { getRegistriesConfig } from "@/src/registry/api" import { getRegistriesConfig } from "@/src/registry/api"
import { findUnknownSearchTypes, SEARCHABLE_TYPES } from "@/src/registry/search"
import { registryItemSchema, searchResultsSchema } from "@/src/schema" import { registryItemSchema, searchResultsSchema } from "@/src/schema"
import { getPackageRunner } from "@/src/utils/get-package-manager" import { getPackageRunner } from "@/src/utils/get-package-manager"
import { z } from "zod" import { z } from "zod"
@@ -78,6 +79,41 @@ export function formatSearchResultsWithPagination(
return output 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( export function formatRegistryItems(
items: z.infer<typeof registryItemSchema>[] items: z.infer<typeof registryItemSchema>[]
) { ) {

View File

@@ -287,6 +287,11 @@ export const searchResultItemSchema = z.object({
addCommandArgument: z.string(), addCommandArgument: z.string(),
}) })
export const searchResultErrorSchema = z.object({
registry: z.string(),
message: z.string(),
})
export const searchResultsSchema = z.object({ export const searchResultsSchema = z.object({
pagination: z.object({ pagination: z.object({
total: z.number(), total: z.number(),
@@ -295,6 +300,10 @@ export const searchResultsSchema = z.object({
hasMore: z.boolean(), hasMore: z.boolean(),
}), }),
items: z.array(searchResultItemSchema), 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. // Legacy schema for getRegistriesIndex() backward compatibility.

View File

@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from "vitest" import { describe, expect, it, vi } from "vitest"
import { getRegistry } from "./api" 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", () => { describe("searchRegistries", () => {
it("should fetch and return registries in flat format", async () => { it("should fetch and return registries in flat format", async () => {
@@ -152,6 +163,177 @@ describe("searchRegistries", () => {
mockGetRegistry.mockRestore() 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 () => { it("should return empty items when search has no matches", async () => {
vi.mock("./api", () => ({ vi.mock("./api", () => ({
getRegistry: vi.fn(), getRegistry: vi.fn(),
@@ -653,3 +835,255 @@ describe("buildRegistryItemNameFromRegistry", () => {
expect(result).toBe(expected) 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 { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { z } from "zod" import { z } from "zod"
import { resolveGitHubRegistrySource } from "./address" import { resolveGitHubRegistrySource } from "./address"
import { getRegistry } from "./api" 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( export async function searchRegistries(
registries: string[], registries: string[],
options?: { options?: {
query?: string query?: string
types?: string[]
limit?: number limit?: number
offset?: number offset?: number
config?: Partial<Config> config?: Partial<Config>
useCache?: boolean 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>[] = [] let allItems: z.infer<typeof searchResultItemSchema>[] = []
const errors: z.infer<typeof searchResultErrorSchema>[] = []
for (const registry of registries) { // Fetch registries concurrently (capped), then process the results in the
const registryData = await getRegistry(registry, { config, useCache }) // 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, name: item.name,
type: item.type, type: item.type,
description: item.description, description: item.description,
registry: registry, registry,
addCommandArgument: buildRegistryItemNameFromRegistry( addCommandArgument: buildRegistryItemNameFromRegistry(
item.name, item.name,
registry registry
@@ -37,6 +140,19 @@ export async function searchRegistries(
allItems = allItems.concat(itemsWithRegistry) 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) { if (query) {
allItems = searchItems(allItems, { allItems = searchItems(allItems, {
query, query,
@@ -57,6 +173,9 @@ export async function searchRegistries(
hasMore: paginationOffset + paginationLimit < totalItems, hasMore: paginationOffset + paginationLimit < totalItems,
}, },
items: allItems.slice(paginationOffset, paginationOffset + paginationLimit), 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) return searchResultsSchema.parse(result)
@@ -179,3 +298,144 @@ export function buildRegistryItemNameFromRegistry(
return hostPart + updatedPath + updatedQuery 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 { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
import { configureRegistries, createRegistryServer } from "../utils/registry" import { configureRegistries, createRegistryServer } from "../utils/registry"
async function runSearch(fixturePath: string, args: string[]) {
return npxShadcn(fixturePath, [...args, "--json"])
}
const registryShadcn = await createRegistryServer( const registryShadcn = await createRegistryServer(
[ [
{ {
@@ -186,7 +190,7 @@ describe("shadcn search", () => {
await configureRegistries(fixturePath, { await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}", "@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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -225,7 +229,7 @@ describe("shadcn search", () => {
"@two": "http://localhost:9182/registry/{name}", "@two": "http://localhost:9182/registry/{name}",
}) })
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"@one", "@one",
@@ -249,7 +253,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}", "@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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") 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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -337,7 +341,7 @@ describe("shadcn search", () => {
try { try {
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN" 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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -379,7 +383,7 @@ describe("shadcn search", () => {
await configureRegistries(fixturePath, { await configureRegistries(fixturePath, {
"@shadcn": "http://localhost:9180/r/{name}", "@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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -419,7 +423,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}", "@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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -522,7 +526,7 @@ describe("shadcn search", () => {
"@two": "http://localhost:9182/registry/{name}", "@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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -549,7 +553,7 @@ describe("shadcn search", () => {
}) })
// List from both registries // 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) const parsed = JSON.parse(output.stdout)
expect(parsed).toHaveProperty("items") expect(parsed).toHaveProperty("items")
@@ -577,7 +581,7 @@ describe("shadcn search", () => {
}) })
// Search for "button" with pagination // Search for "button" with pagination
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -605,7 +609,7 @@ describe("shadcn search", () => {
"@shadcn": "http://localhost:9180/r/{name}", "@shadcn": "http://localhost:9180/r/{name}",
}) })
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -644,7 +648,7 @@ describe("shadcn search", () => {
// Test button typos // Test button typos
for (const typo of typos.slice(0, 4)) { for (const typo of typos.slice(0, 4)) {
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -658,7 +662,7 @@ describe("shadcn search", () => {
} }
// Test dialog typo // Test dialog typo
const dialogOutput = await npxShadcn(fixturePath, [ const dialogOutput = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -671,7 +675,7 @@ describe("shadcn search", () => {
).toBe(true) ).toBe(true)
// Test alert typo // Test alert typo
const alertOutput = await npxShadcn(fixturePath, [ const alertOutput = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -699,7 +703,7 @@ describe("shadcn search", () => {
] ]
for (const { query, expected } of partialQueries) { for (const { query, expected } of partialQueries) {
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -720,19 +724,19 @@ describe("shadcn search", () => {
}) })
// Search with different cases // Search with different cases
const output1 = await npxShadcn(fixturePath, [ const output1 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
"BUTTON", "BUTTON",
]) ])
const output2 = await npxShadcn(fixturePath, [ const output2 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
"button", "button",
]) ])
const output3 = await npxShadcn(fixturePath, [ const output3 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -757,7 +761,7 @@ describe("shadcn search", () => {
}) })
// Test searching for components with hyphens // Test searching for components with hyphens
const output1 = await npxShadcn(fixturePath, [ const output1 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -769,7 +773,7 @@ describe("shadcn search", () => {
).toBe(true) ).toBe(true)
// Test searching with just the hyphen part // Test searching with just the hyphen part
const output2 = await npxShadcn(fixturePath, [ const output2 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -781,7 +785,7 @@ describe("shadcn search", () => {
).toBe(true) ).toBe(true)
// Test with spaces (should still work) // Test with spaces (should still work)
const output3 = await npxShadcn(fixturePath, [ const output3 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -800,7 +804,7 @@ describe("shadcn search", () => {
}) })
// Search for "bar" which should match "bar" exactly // Search for "bar" which should match "bar" exactly
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@one", "@one",
"--query", "--query",
@@ -821,7 +825,7 @@ describe("shadcn search", () => {
}) })
// Use 'search' instead of 'list' // Use 'search' instead of 'list'
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--query", "--query",
@@ -846,7 +850,7 @@ describe("shadcn search", () => {
}) })
// Test with limit 0 - should return all items // Test with limit 0 - should return all items
const output1 = await npxShadcn(fixturePath, [ const output1 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--limit", "--limit",
@@ -856,7 +860,7 @@ describe("shadcn search", () => {
expect(parsed1.items.length).toBeGreaterThan(0) expect(parsed1.items.length).toBeGreaterThan(0)
// Test with very large limit // Test with very large limit
const output2 = await npxShadcn(fixturePath, [ const output2 = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"--limit", "--limit",
@@ -875,7 +879,7 @@ describe("shadcn search", () => {
}) })
// Search across multiple registries with pagination // Search across multiple registries with pagination
const output = await npxShadcn(fixturePath, [ const output = await runSearch(fixturePath, [
"search", "search",
"@shadcn", "@shadcn",
"@one", "@one",
@@ -900,7 +904,7 @@ describe("shadcn search", () => {
}) })
// First page // First page
const output1 = await npxShadcn(fixturePath, [ const output1 = await runSearch(fixturePath, [
"search", "search",
"@large", "@large",
"--limit", "--limit",
@@ -920,7 +924,7 @@ describe("shadcn search", () => {
}) })
// Middle page // Middle page
const output2 = await npxShadcn(fixturePath, [ const output2 = await runSearch(fixturePath, [
"search", "search",
"@large", "@large",
"--limit", "--limit",
@@ -935,7 +939,7 @@ describe("shadcn search", () => {
expect(parsed2.pagination.hasMore).toBe(true) expect(parsed2.pagination.hasMore).toBe(true)
// Last page (partial) // Last page (partial)
const output3 = await npxShadcn(fixturePath, [ const output3 = await runSearch(fixturePath, [
"search", "search",
"@large", "@large",
"--limit", "--limit",
@@ -957,7 +961,7 @@ describe("shadcn search", () => {
"@one": "http://localhost:9181/r/{name}", "@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) const parsed = JSON.parse(output.stdout)
// Check that we only get name, type, description, registry, and addCommand fields // 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}", "@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) const parsed = JSON.parse(output.stdout)
// Check @one registry items have correct addCommand // Check @one registry items have correct addCommand
@@ -1001,4 +1005,115 @@ describe("shadcn search", () => {
) )
expect(itemItem.addCommandArgument).toBe("@two/item") 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 @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats" npx shadcn@latest search @tailark -q "stats"
npx shadcn@latest search owner/repo -q "login" 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. # Get component docs and example URLs.
npx shadcn@latest docs button dialog select 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 ### `search` — Search registries
```bash ```bash
npx shadcn@latest search <registries...> [options] npx shadcn@latest search [registries...] [options]
``` ```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`), 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 | | Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- | | ------------------- | ----- | ------------------------------------------------- | ------- |
| `--query <query>` | `-q` | Search query | — | | `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` | | `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
| `--offset <number>` | `-o` | Items to skip | `0` | | `--limit <number>` | `-l` | Max items to display | `100` |
| `--cwd <cwd>` | `-c` | Working directory | current | | `--offset <number>` | `-o` | Items to skip | `0` |
| `--json` | | Output as JSON | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details ### `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 Lists all items from one or more registries. Registries can be configured
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or 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` ### `shadcn:search_items_in_registries`
Fuzzy search across registries. Registries can be configured namespaces, public 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` ### `shadcn:view_items_in_registries`
@@ -57,9 +60,10 @@ View item details including full file contents.
### `shadcn:get_item_examples_from_registries` ### `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` ### `shadcn:get_add_command_for_items`