diff --git a/.changeset/pink-rivers-turn.md b/.changeset/pink-rivers-turn.md new file mode 100644 index 0000000000..757a3e4386 --- /dev/null +++ b/.changeset/pink-rivers-turn.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +improve search command diff --git a/packages/shadcn/src/commands/search.test.ts b/packages/shadcn/src/commands/search.test.ts index 8bdb72a10b..1fc47c1e07 100644 --- a/packages/shadcn/src/commands/search.test.ts +++ b/packages/shadcn/src/commands/search.test.ts @@ -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()), + 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() { diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts index 4e2584cc83..a9a5e6f58a 100644 --- a/packages/shadcn/src/commands/search.ts +++ b/packages/shadcn/src/commands/search.ts @@ -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( - "", - "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 ", @@ -37,20 +46,44 @@ export const search = new Command() ) .option("-q, --query ", "query string") .option( - "-l, --limit ", - "maximum number of items to display per registry", - "100" + "-t, --type ", + "filter by item type, e.g. ui, block, hook. Comma-separated for multiple." ) + .option("-l, --limit ", "maximum number of items to display", "100") .option("-o, --offset ", "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 { diff --git a/packages/shadcn/src/mcp/index.ts b/packages/shadcn/src/mcp/index.ts index 4daca0e3cc..549aa06a87 100644 --- a/packages/shadcn/src/mcp/index.ts +++ b/packages/shadcn/src/mcp/index.ts @@ -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) { diff --git a/packages/shadcn/src/mcp/utils.ts b/packages/shadcn/src/mcp/utils.ts index 5877420533..d990e8cb58 100644 --- a/packages/shadcn/src/mcp/utils.ts +++ b/packages/shadcn/src/mcp/utils.ts @@ -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 +) { + 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[] ) { diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index ff24e3f531..7948ee35be 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -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. diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts index e9a49bd46a..ff11f1545d 100644 --- a/packages/shadcn/src/registry/search.test.ts +++ b/packages/shadcn/src/registry/search.test.ts @@ -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"]) + }) +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts index cc2ac12dd1..fc17a6c4a9 100644 --- a/packages/shadcn/src/registry/search.ts +++ b/packages/shadcn/src/registry/search.ts @@ -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 +): 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( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise[]> { + const results: PromiseSettledResult[] = 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 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[] = [] + const errors: z.infer[] = [] - 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["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, + 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.` + ) + } +} diff --git a/packages/tests/src/tests/search.test.ts b/packages/tests/src/tests/search.test.ts index f92f2934de..02ad10cb4e 100644 --- a/packages/tests/src/tests/search.test.ts +++ b/packages/tests/src/tests/search.test.ts @@ -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" })]) + ) + }) }) diff --git a/skills/shadcn/SKILL.md b/skills/shadcn/SKILL.md index 7d0014bb84..dcdb754588 100644 --- a/skills/shadcn/SKILL.md +++ b/skills/shadcn/SKILL.md @@ -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 diff --git a/skills/shadcn/cli.md b/skills/shadcn/cli.md index 742b01b1bb..8a1d195871 100644 --- a/skills/shadcn/cli.md +++ b/skills/shadcn/cli.md @@ -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 [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 ` | `-q` | Search query | — | -| `--limit ` | `-l` | Max items per registry | `100` | -| `--offset ` | `-o` | Items to skip | `0` | -| `--cwd ` | `-c` | Working directory | current | +| Flag | Short | Description | Default | +| ------------------- | ----- | ------------------------------------------------- | ------- | +| `--query ` | `-q` | Search query | — | +| `--type ` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — | +| `--limit ` | `-l` | Max items to display | `100` | +| `--offset ` | `-o` | Items to skip | `0` | +| `--json` | | Output as JSON | `false` | +| `--cwd ` | `-c` | Working directory | current | ### `view` — View item details diff --git a/skills/shadcn/mcp.md b/skills/shadcn/mcp.md index 814604f56c..6539ddaa2d 100644 --- a/skills/shadcn/mcp.md +++ b/skills/shadcn/mcp.md @@ -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`