feat(shadcn): new mcp server (#8012)

* feat(shadcn): add getRegistriesConfig api

* feat(shadcn): add new mcp command

* feat(shadcn): add get_item_examples_from_registries and get_add_command_for_items

* feat: remove getRegistriesConfig

* chore: changeset
This commit is contained in:
shadcn
2025-08-13 11:15:26 +04:00
committed by GitHub
parent a941287411
commit 296feb28a2
15 changed files with 1076 additions and 189 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": major
---
add new mcp server and command

View File

@@ -60,7 +60,8 @@
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
"pub:release": "pnpm build && pnpm publish --access public",
"test": "vitest run",
"test:dev": "REGISTRY_URL=http://localhost:4000/r vitest run"
"test:dev": "REGISTRY_URL=http://localhost:4000/r vitest run",
"mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp"
},
"dependencies": {
"@antfu/ni": "^25.0.0",
@@ -71,6 +72,7 @@
"@modelcontextprotocol/sdk": "^1.17.2",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dedent": "^1.6.0",
"deepmerge": "^4.3.1",
"diff": "^8.0.2",
"execa": "^9.6.0",

View File

@@ -0,0 +1,23 @@
import { server } from "@/src/mcp"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { Command } from "commander"
export const mcp = new Command()
.name("mcp")
.description("starts the registry MCP server")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async () => {
try {
const transport = new StdioServerTransport()
await server.connect(transport)
} catch (error) {
logger.break()
handleError(error)
}
})

View File

@@ -1,23 +1,22 @@
import { server } from "@/src/mcp"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { Command } from "commander"
export const mcp = new Command()
.name("registry:mcp")
.description("starts the registry MCP server [EXPERIMENTAL]")
.description("starts the registry MCP server [DEPRECATED]")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async () => {
try {
const transport = new StdioServerTransport()
await server.connect(transport)
} catch (error) {
logger.break()
handleError(error)
}
logger.warn(
`The ${highlighter.info(
"shadcn registry:mcp"
)} command is deprecated. Use the ${highlighter.info(
"shadcn mcp"
)} command instead.`
)
logger.break()
})

View File

@@ -23,6 +23,7 @@ const searchOptionsSchema = z.object({
export const search = new Command()
.name("search")
.alias("list")
.description("search items from registries")
.argument(
"<registries...>",

View File

@@ -4,6 +4,7 @@ import { build } from "@/src/commands/build"
import { diff } from "@/src/commands/diff"
import { info } from "@/src/commands/info"
import { init } from "@/src/commands/init"
import { mcp } from "@/src/commands/mcp"
import { migrate } from "@/src/commands/migrate"
import { build as registryBuild } from "@/src/commands/registry/build"
import { mcp as registryMcp } from "@/src/commands/registry/mcp"
@@ -35,7 +36,7 @@ async function main() {
.addCommand(migrate)
.addCommand(info)
.addCommand(build)
.addCommand(mcp)
// Registry commands
program.addCommand(registryBuild).addCommand(registryMcp)

View File

@@ -1,18 +1,26 @@
import { getRegistryItems } from "@/src/registry/api"
import { fetchRegistry } from "@/src/registry/fetcher"
import { registrySchema } from "@/src/schema"
import { getRegistryItems, searchRegistries } from "@/src/registry"
import { RegistryError } from "@/src/registry/errors"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import dedent from "dedent"
import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import {
formatItemExamples,
formatRegistryItems,
formatSearchResultsWithPagination,
getMcpConfig,
npxShadcn,
} from "./utils"
export const server = new Server(
{
name: "shadcn",
version: "0.0.1",
version: "1.0.0",
},
{
capabilities: {
@@ -26,38 +34,103 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "init",
name: "get_project_registries",
description:
"Initialize a new project using a registry style project structure.",
"Get configured registry names from components.json - Returns error if no components.json exists (use init_project to create one)",
inputSchema: zodToJsonSchema(z.object({})),
},
{
name: "get_items",
description: "List all the available items in the registry",
inputSchema: zodToJsonSchema(z.object({})),
},
{
name: "get_item",
description: "Get an item from the registry",
name: "list_items_in_registries",
description:
"List items from registries (requires components.json - use init_project if missing)",
inputSchema: zodToJsonSchema(
z.object({
name: z
registries: z
.array(z.string())
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
),
limit: z
.number()
.optional()
.describe("Maximum number of items to return"),
offset: z
.number()
.optional()
.describe("Number of items to skip for pagination"),
})
),
},
{
name: "search_items_in_registries",
description:
"Search for components in registries using fuzzy matching (requires components.json). After finding an item, use get_item_examples_from_registries to see usage examples.",
inputSchema: zodToJsonSchema(
z.object({
registries: z
.array(z.string())
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
),
query: z
.string()
.describe(
"The name of the item to get from the registry. This is required."
"Search query string for fuzzy matching against item names and descriptions"
),
limit: z
.number()
.optional()
.describe("Maximum number of items to return"),
offset: z
.number()
.optional()
.describe("Number of items to skip for pagination"),
})
),
},
{
name: "view_items_in_registries",
description:
"View detailed information about specific registry items including the name, description, type and files content. For usage examples, use get_item_examples_from_registries instead.",
inputSchema: zodToJsonSchema(
z.object({
items: z
.array(z.string())
.describe(
"Array of item names with registry prefix (e.g., ['@shadcn/button', '@shadcn/card'])"
),
})
),
},
{
name: "add_item",
description: "Add an item from the registry",
name: "get_item_examples_from_registries",
description:
"Find usage examples and demos with their complete code. Search for patterns like 'accordion-demo', 'button example', 'card-demo', etc. Returns full implementation code with dependencies.",
inputSchema: zodToJsonSchema(
z.object({
name: z
registries: z
.array(z.string())
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
),
query: z
.string()
.describe(
"The name of the item to add to the registry. This is required."
"Search query for examples (e.g., 'accordion-demo', 'button demo', 'card example', 'tooltip-demo', 'example-booking-form', 'example-hero'). Common patterns: '{item-name}-demo', '{item-name} example', 'example {item-name}'"
),
})
),
},
{
name: "get_add_command_for_items",
description:
"Get the shadcn CLI add command for specific items in a registry. This is useful for adding one or more components to your project.",
inputSchema: zodToJsonSchema(
z.object({
items: z
.array(z.string())
.describe(
"Array of items to get the add command for prefixed with the registry name (e.g., ['@shadcn/button', '@shadcn/card'])"
),
})
),
@@ -69,52 +142,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
if (!request.params.arguments) {
throw new Error("Arguments are required")
}
const REGISTRY_URL = process.env.REGISTRY_URL
if (!REGISTRY_URL) {
throw new Error("REGISTRY_URL is not set")
throw new Error("No tool arguments provided.")
}
switch (request.params.name) {
case "init": {
const registry = await getRegistry(REGISTRY_URL)
const style = registry.items.find(
(item) => item.type === "registry:style"
)
case "get_project_registries": {
const config = await getMcpConfig(process.cwd())
let text = `To initialize a new project, run the following command:
\`\`\`bash
npx shadcn@canary init
\`\`\`
- This will install all the dependencies and theme for the project.
- If running the init command installs a rules i.e registry.mdc file, you should follow the instructions in the file to configure the project.
`
const rules = registry.items.find(
(item) => item.type === "registry:file" && item.name === "rules"
)
if (rules) {
text += `
You should also install the rules for the project.
\`\`\`bash
npx shadcn@canary add ${getRegistryItemUrl(
rules.name,
REGISTRY_URL
)}
\`\`\`
`
}
if (!style) {
if (!config?.registries) {
return {
content: [
{
type: "text",
text,
text: dedent`No components.json found or no registries configured.
To fix this:
1. Use the \`init\` command to create a components.json file
2. Or manually create components.json with a registries section`,
},
],
}
@@ -124,29 +168,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [
{
type: "text",
text: `To initialize a new project using the ${
style.name
} style, run the following command:
\`\`\`bash
npx shadcn@canary init ${getRegistryItemUrl(
style.name,
REGISTRY_URL
)}
\`\`\`
`,
text: dedent`The following registries are configured in the current project:
${Object.keys(config.registries)
.map((registry) => `- ${registry}`)
.join("\n")}
You can view the items in a registry by running:
\`${await npxShadcn("view @name-of-registry")}\`
For example: \`${await npxShadcn(
"view @shadcn"
)}\` or \`${await npxShadcn(
"view @shadcn @acme"
)}\` to view multiple registries.
`,
},
],
}
}
case "get_items": {
const registry = await getRegistry(REGISTRY_URL)
if (!registry.items) {
case "search_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()),
query: z.string(),
limit: z.number().optional(),
offset: z.number().optional(),
})
const args = inputSchema.parse(request.params.arguments)
const results = await searchRegistries(args.registries, {
query: args.query,
limit: args.limit,
offset: args.offset,
config: await getMcpConfig(process.cwd()),
useCache: false,
})
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: "No items found in the registry",
text: dedent`No items found matching "${
args.query
}" in registries ${args.registries.join(
", "
)}, Try searching with a different query or registry.`,
},
],
}
@@ -156,111 +224,213 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [
{
type: "text",
text: `The following items are available in the registry:
${JSON.stringify(
registry.items.map(
(item) => `- ${item.name} (${item.type}): ${item.description}`
),
null,
2
)}.
- To install and use an item in your project, you run the following command:
\`\`\`bash
npx shadcn@canary add ${getRegistryItemUrl(
"NAME_OF_THE_ITEM",
REGISTRY_URL
)}
\`\`\`
- Example: npx shadcn@canary add ${getRegistryItemUrl(
registry.items[0].name,
REGISTRY_URL
)} to install the ${registry.items[0].name} item.
- To install multiple registry.items, you can do the following:
\`\`\`bash
npx shadcn@canary add ${getRegistryItemUrl(
"NAME_OF_THE_ITEM_1",
REGISTRY_URL
)} ${getRegistryItemUrl("NAME_OF_THE_ITEM_2", REGISTRY_URL)}
\`\`\`
- Before using any item, you need to add it first.
- Adding the items will install all dependencies for the item and format the code as per the project.
- Example components should not be installed directly unless asked. These components should be used as a reference to build other components.
`,
text: formatSearchResultsWithPagination(results, {
query: args.query,
registries: args.registries,
}),
},
],
}
}
case "get_item": {
const name = z.string().parse(request.params.arguments.name)
case "list_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()),
limit: z.number().optional(),
offset: z.number().optional(),
cwd: z.string().optional(),
})
if (!name) {
throw new Error("Name is required")
const args = inputSchema.parse(request.params.arguments)
const results = await searchRegistries(args.registries, {
limit: args.limit,
offset: args.offset,
config: await getMcpConfig(process.cwd()),
useCache: false,
})
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found in registries ${args.registries.join(
", "
)}.`,
},
],
}
}
const [item] = await getRegistryItems([name], {
return {
content: [
{
type: "text",
text: formatSearchResultsWithPagination(results, {
registries: args.registries,
}),
},
],
}
}
case "view_items_in_registries": {
const inputSchema = z.object({
items: z.array(z.string()),
})
const args = inputSchema.parse(request.params.arguments)
const registryItems = await getRegistryItems(args.items, {
config: await getMcpConfig(process.cwd()),
useCache: false,
})
if (registryItems?.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found for: ${args.items.join(", ")}
Make sure the item names are correct and include the registry prefix (e.g., @shadcn/button).`,
},
],
}
}
const formattedItems = formatRegistryItems(registryItems)
return {
content: [
{
type: "text",
text: dedent`Item Details:
${formattedItems.join("\n\n---\n\n")}`,
},
],
}
}
case "get_item_examples_from_registries": {
const inputSchema = z.object({
query: z.string(),
registries: z.array(z.string()),
})
const args = inputSchema.parse(request.params.arguments)
const config = await getMcpConfig()
const results = await searchRegistries(args.registries, {
query: args.query,
config,
useCache: false,
})
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No examples found for query "${args.query}".
Try searching with patterns like:
- "accordion-demo" for accordion examples
- "button demo" or "button example"
- Component name followed by "-demo" or "example"
You can also:
1. Use search_items_in_registries to find all items matching your query
2. View the main component with view_items_in_registries for inline usage documentation`,
},
],
}
}
const itemNames = results.items.map((item) => item.addCommandArgument)
const fullItems = await getRegistryItems(itemNames, {
config,
useCache: false,
})
return {
content: [{ type: "text", text: JSON.stringify(item, null, 2) }],
}
}
case "add_item": {
const name = z.string().parse(request.params.arguments.name)
if (!name) {
throw new Error("Name is required")
}
const itemUrl = getRegistryItemUrl(name, REGISTRY_URL)
const item = await getRegistryItems([itemUrl], {
useCache: false,
})
if (!item) {
return {
content: [
{
type: "text",
text: `Item ${name} not found in the registry.`,
},
],
}
}
return {
content: [
{
type: "text",
text: `To install the ${name} item, run the following command:
\`\`\`bash
npx shadcn@canary add ${itemUrl}
\`\`\``,
text: formatItemExamples(fullItems, args.query),
},
],
}
}
case "get_add_command_for_items": {
const args = z
.object({
items: z.array(z.string()),
})
.parse(request.params.arguments)
return {
content: [
{
type: "text",
text: await npxShadcn(`add ${args.items.join(" ")}`),
},
],
}
}
default:
throw new Error(`Tool ${request.params.name} not found`)
}
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`)
return {
content: [
{
type: "text",
text: dedent`Invalid input parameters:
${error.errors
.map((e) => `- ${e.path.join(".")}: ${e.message}`)
.join("\n")}
`,
},
],
isError: true,
}
}
throw error
if (error instanceof RegistryError) {
let errorMessage = error.message
if (error.suggestion) {
errorMessage += `\n\n💡 ${error.suggestion}`
}
if (error.context) {
errorMessage += `\n\nContext: ${JSON.stringify(error.context, null, 2)}`
}
return {
content: [
{
type: "text",
text: dedent`Error (${error.code}): ${errorMessage}`,
},
],
isError: true,
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: "text",
text: dedent`Error: ${errorMessage}`,
},
],
isError: true,
}
}
})
async function getRegistry(registryUrl: string) {
const [registryJson] = await fetchRegistry([registryUrl], {
useCache: false,
})
return registrySchema.parse(registryJson)
}
function getRegistryItemUrl(itemName: string, registryUrl: string) {
const registryBaseUrl = registryUrl.replace(/\/registry\.json$/, "")
return `${registryBaseUrl}/${itemName}.json`
}

View File

@@ -0,0 +1,132 @@
import { getRegistriesConfig } from "@/src/registry/api"
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
import { getPackageRunner } from "@/src/utils/get-package-manager"
import { z } from "zod"
const SHADCN_CLI_COMMAND = "shadcn@beta"
export async function npxShadcn(command: string) {
const packageRunner = await getPackageRunner(process.cwd())
return `${packageRunner} ${SHADCN_CLI_COMMAND} ${command}`
}
export async function getMcpConfig(cwd = process.cwd()) {
const config = await getRegistriesConfig(cwd, {
useCache: false,
})
return {
registries: config.registries,
}
}
export function formatSearchResultsWithPagination(
results: z.infer<typeof searchResultsSchema>,
options?: {
query?: string
registries?: string[]
}
) {
const { query, registries } = options || {}
const formattedItems = results.items.map((item) => {
const parts: string[] = [`- ${item.name}`]
if (item.type) {
parts.push(`(${item.type})`)
}
if (item.description) {
parts.push(`- ${item.description}`)
}
if (item.registry) {
parts.push(`[${item.registry}]`)
}
parts.push(
`\n Add command: \`${npxShadcn(`add ${item.addCommandArgument}`)}\``
)
return parts.join(" ")
})
let header = `Found ${results.pagination.total} items`
if (query) {
header += ` matching "${query}"`
}
if (registries && registries.length > 0) {
header += ` in registries ${registries.join(", ")}`
}
header += ":"
const showingRange = `Showing items ${
results.pagination.offset + 1
}-${Math.min(
results.pagination.offset + results.pagination.limit,
results.pagination.total
)} of ${results.pagination.total}:`
let output = `${header}\n\n${showingRange}\n\n${formattedItems.join("\n\n")}`
if (results.pagination.hasMore) {
output += `\n\nMore items available. Use offset: ${
results.pagination.offset + results.pagination.limit
} to see the next page.`
}
return output
}
export function formatRegistryItems(
items: z.infer<typeof registryItemSchema>[]
) {
return items.map((item) => {
const parts: string[] = [
`## ${item.name}`,
item.description ? `\n${item.description}\n` : "",
item.type ? `**Type:** ${item.type}` : "",
item.files && item.files.length > 0
? `**Files:** ${item.files.length} file(s)`
: "",
item.dependencies && item.dependencies.length > 0
? `**Dependencies:** ${item.dependencies.join(", ")}`
: "",
item.devDependencies && item.devDependencies.length > 0
? `**Dev Dependencies:** ${item.devDependencies.join(", ")}`
: "",
]
return parts.filter(Boolean).join("\n")
})
}
export function formatItemExamples(
items: z.infer<typeof registryItemSchema>[],
query: string
) {
const sections = items.map((item) => {
const parts: string[] = [
`## Example: ${item.name}`,
item.description ? `\n${item.description}\n` : "",
]
if (item.files?.length) {
item.files.forEach((file) => {
if (file.content) {
parts.push(`### Code (${file.path}):\n`)
parts.push("```tsx")
parts.push(file.content)
parts.push("```")
}
})
}
return parts.filter(Boolean).join("\n")
})
const header = `# Usage Examples\n\nFound ${items.length} example${
items.length > 1 ? "s" : ""
} matching "${query}":\n`
return header + sections.join("\n\n---\n\n")
}

View File

@@ -1,8 +1,9 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { REGISTRY_URL } from "@/src/registry/constants"
import { BUILTIN_REGISTRIES, REGISTRY_URL } from "@/src/registry/constants"
import {
ConfigParseError,
RegistryErrorCode,
RegistryFetchError,
RegistryForbiddenError,
@@ -26,7 +27,7 @@ import {
} from "vitest"
import { z } from "zod"
import { getRegistry, getRegistryItems } from "./api"
import { getRegistriesConfig, getRegistry, getRegistryItems } from "./api"
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn(),
@@ -1190,3 +1191,463 @@ describe("getRegistry", () => {
expect(nameResult).toBeDefined()
})
})
describe("getRegistriesConfig", () => {
it("should return built-in registries when no components.json exists", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
try {
const result = await getRegistriesConfig(tempDir)
expect(result).toEqual({
registries: BUILTIN_REGISTRIES,
})
} finally {
await fs.rmdir(tempDir)
}
})
it("should parse valid components.json with registries", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const config = {
style: "new-york",
tailwind: {
config: "./tailwind.config.ts",
css: "./src/app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
registries: {
"@acme": "https://acme.com/{name}.json",
"@private": {
url: "https://private.com/{name}.json",
headers: {
Authorization: "Bearer token",
},
},
},
}
await fs.writeFile(configFile, JSON.stringify(config, null, 2))
try {
const result = await getRegistriesConfig(tempDir)
// The result includes both custom and built-in registries
expect(result.registries).toMatchObject({
"@acme": "https://acme.com/{name}.json",
"@private": {
url: "https://private.com/{name}.json",
headers: {
Authorization: "Bearer token",
},
},
"@shadcn": expect.any(String),
})
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should throw ConfigParseError for invalid components.json", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const invalidConfig = {
style: "new-york",
registries: {
"@invalid": {
// Missing required 'url' field
headers: {
Authorization: "Bearer token",
},
},
},
}
await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2))
try {
await getRegistriesConfig(tempDir)
expect.fail("Should have thrown ConfigParseError")
} catch (error) {
expect(error).toBeInstanceOf(ConfigParseError)
if (error instanceof ConfigParseError) {
expect(error.cwd).toBe(tempDir)
expect(error.message).toContain("Invalid components.json")
}
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should handle components.json with no registries field", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const config = {
style: "new-york",
tailwind: {
config: "./tailwind.config.ts",
css: "./src/app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
// No registries field
}
await fs.writeFile(configFile, JSON.stringify(config, null, 2))
try {
const result = await getRegistriesConfig(tempDir)
// When no registries are defined, built-in registries are still included
expect(result.registries).toEqual(BUILTIN_REGISTRIES)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should handle malformed JSON file", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
await fs.writeFile(configFile, "{ invalid json }")
try {
// Malformed JSON should throw an error from cosmiconfig
await getRegistriesConfig(tempDir)
expect.fail("Should have thrown an error")
} catch (error) {
// cosmiconfig throws a JSONError for malformed JSON
expect((error as Error).message).toContain("JSON Error")
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should include error details in ConfigParseError", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const invalidConfig = {
style: "new-york",
registries: {
"@invalid": {
url: 123, // Should be string
},
},
}
await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2))
try {
await getRegistriesConfig(tempDir)
expect.fail("Should have thrown ConfigParseError")
} catch (error) {
expect(error).toBeInstanceOf(ConfigParseError)
if (error instanceof ConfigParseError) {
expect(error.cwd).toBe(tempDir)
expect(error.message).toContain("Invalid components.json")
expect(error.message).toContain(tempDir)
expect(error.code).toBe(RegistryErrorCode.INVALID_CONFIG)
expect(error.suggestion).toContain("Check your components.json")
}
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should handle complex registry configurations", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const config = {
style: "new-york",
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
"@acme": "https://acme.com/registry/{name}.json",
"@private": {
url: "https://private.registry.com/{name}.json",
headers: {
Authorization: "Bearer ${REGISTRY_TOKEN}",
"X-Custom-Header": "value",
},
},
"@local": "file:///local/registry/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(config, null, 2))
try {
const result = await getRegistriesConfig(tempDir)
expect(result.registries).toBeDefined()
expect(result.registries?.["@shadcn"]).toBe(
"https://ui.shadcn.com/r/styles/{style}/{name}.json"
)
expect(result.registries?.["@acme"]).toBe(
"https://acme.com/registry/{name}.json"
)
expect(result.registries?.["@private"]).toEqual({
url: "https://private.registry.com/{name}.json",
headers: {
Authorization: "Bearer ${REGISTRY_TOKEN}",
"X-Custom-Header": "value",
},
})
expect(result.registries?.["@local"]).toBe(
"file:///local/registry/{name}.json"
)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
describe("caching behavior", () => {
it("should use cache by default", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const initialConfig = {
style: "new-york",
registries: {
"@cached": "https://cached.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2))
try {
// First call - should read from file
const result1 = await getRegistriesConfig(tempDir)
expect(result1.registries?.["@cached"]).toBe(
"https://cached.com/{name}.json"
)
// Modify the file
const updatedConfig = {
style: "new-york",
registries: {
"@cached": "https://updated.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2))
// Second call - should still return cached result (default behavior)
const result2 = await getRegistriesConfig(tempDir)
expect(result2.registries?.["@cached"]).toBe(
"https://cached.com/{name}.json"
)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should use cache when explicitly enabled", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const initialConfig = {
style: "new-york",
registries: {
"@test": "https://test.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2))
try {
// First call with cache enabled
const result1 = await getRegistriesConfig(tempDir, { useCache: true })
expect(result1.registries?.["@test"]).toBe(
"https://test.com/{name}.json"
)
// Modify the file
const updatedConfig = {
style: "new-york",
registries: {
"@test": "https://modified.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2))
// Second call with cache enabled - should return cached result
const result2 = await getRegistriesConfig(tempDir, { useCache: true })
expect(result2.registries?.["@test"]).toBe(
"https://test.com/{name}.json"
)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should bypass cache when useCache is false", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const initialConfig = {
style: "new-york",
registries: {
"@nocache": "https://nocache.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2))
try {
// First call - should read from file
const result1 = await getRegistriesConfig(tempDir, { useCache: false })
expect(result1.registries?.["@nocache"]).toBe(
"https://nocache.com/{name}.json"
)
// Modify the file
const updatedConfig = {
style: "new-york",
registries: {
"@nocache": "https://fresh.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2))
// Second call with cache disabled - should read fresh data
const result2 = await getRegistriesConfig(tempDir, { useCache: false })
expect(result2.registries?.["@nocache"]).toBe(
"https://fresh.com/{name}.json"
)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should clear cache for subsequent calls when useCache is false", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const config1 = {
style: "new-york",
registries: {
"@step1": "https://step1.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(config1, null, 2))
try {
// First call with cache enabled
const result1 = await getRegistriesConfig(tempDir, { useCache: true })
expect(result1.registries?.["@step1"]).toBe(
"https://step1.com/{name}.json"
)
// Update config
const config2 = {
style: "new-york",
registries: {
"@step2": "https://step2.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(config2, null, 2))
// Call with cache disabled - should clear cache and read fresh
const result2 = await getRegistriesConfig(tempDir, { useCache: false })
expect(result2.registries?.["@step2"]).toBe(
"https://step2.com/{name}.json"
)
expect(result2.registries?.["@step1"]).toBeUndefined()
// Update config again
const config3 = {
style: "new-york",
registries: {
"@step3": "https://step3.com/{name}.json",
},
}
await fs.writeFile(configFile, JSON.stringify(config3, null, 2))
// Call with cache disabled again to ensure we get fresh data
const result3 = await getRegistriesConfig(tempDir, { useCache: false })
expect(result3.registries?.["@step3"]).toBe(
"https://step3.com/{name}.json"
)
expect(result3.registries?.["@step2"]).toBeUndefined()
// Now a call with cache enabled should cache the current state
const result4 = await getRegistriesConfig(tempDir, { useCache: true })
expect(result4.registries?.["@step3"]).toBe(
"https://step3.com/{name}.json"
)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
it("should handle missing config with cache options", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
try {
// With cache enabled (default)
const result1 = await getRegistriesConfig(tempDir)
expect(result1.registries).toEqual(BUILTIN_REGISTRIES)
// With cache explicitly enabled
const result2 = await getRegistriesConfig(tempDir, { useCache: true })
expect(result2.registries).toEqual(BUILTIN_REGISTRIES)
// With cache disabled
const result3 = await getRegistriesConfig(tempDir, { useCache: false })
expect(result3.registries).toEqual(BUILTIN_REGISTRIES)
} finally {
await fs.rmdir(tempDir)
}
})
it("should handle invalid config with cache options", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const configFile = path.join(tempDir, "components.json")
const invalidConfig = {
style: "new-york",
registries: {
"@invalid": {
// Missing required 'url' field
headers: {
Authorization: "Bearer token",
},
},
},
}
await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2))
try {
// Should throw regardless of cache setting
await expect(
getRegistriesConfig(tempDir, { useCache: true })
).rejects.toThrow(ConfigParseError)
await expect(
getRegistriesConfig(tempDir, { useCache: false })
).rejects.toThrow(ConfigParseError)
} finally {
await fs.unlink(configFile)
await fs.rmdir(tempDir)
}
})
})
})

View File

@@ -1,12 +1,13 @@
import path from "path"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
import { configWithDefaults } from "@/src/registry/config"
import { BASE_COLORS } from "@/src/registry/constants"
import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants"
import {
clearRegistryContext,
setRegistryHeaders,
} from "@/src/registry/context"
import {
ConfigParseError,
RegistryInvalidNamespaceError,
RegistryNotFoundError,
RegistryParseError,
@@ -19,13 +20,15 @@ import {
import { isUrl } from "@/src/registry/utils"
import {
iconsSchema,
rawConfigSchema,
registryBaseColorSchema,
registryConfigSchema,
registryIndexSchema,
registryItemSchema,
registrySchema,
stylesSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { Config, explorer } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { z } from "zod"
@@ -108,6 +111,47 @@ export async function resolveRegistryItems(
return resolveRegistryTree(items, configWithDefaults(config), { useCache })
}
export async function getRegistriesConfig(
cwd: string,
options?: { useCache?: boolean }
) {
const { useCache = true } = options || {}
// Clear cache if requested
if (!useCache) {
explorer.clearCaches()
}
const configResult = await explorer.search(cwd)
if (!configResult) {
// Do not throw an error if the config is missing.
// We still have access to the built-in registries.
return {
registries: BUILTIN_REGISTRIES,
}
}
// Parse just the registries field from the config
const registriesConfig = z
.object({
registries: registryConfigSchema.optional(),
})
.safeParse(configResult.config)
if (!registriesConfig.success) {
throw new ConfigParseError(cwd, registriesConfig.error)
}
// Merge built-in registries with user registries
return {
registries: {
...BUILTIN_REGISTRIES,
...(registriesConfig.data.registries || {}),
},
}
}
export async function getShadcnRegistryIndex() {
try {
const [result] = await fetchRegistry(["index.json"])

View File

@@ -250,3 +250,38 @@ export class RegistryInvalidNamespaceError extends RegistryError {
this.name = "RegistryInvalidNamespaceError"
}
}
export class ConfigMissingError extends RegistryError {
constructor(public readonly cwd: string) {
const message = `No components.json found in ${cwd} or parent directories.`
super(message, {
code: RegistryErrorCode.NOT_CONFIGURED,
context: { cwd },
suggestion:
"Run 'npx shadcn@latest init' to create a components.json file, or check that you're in the correct directory.",
})
this.name = "ConfigMissingError"
}
}
export class ConfigParseError extends RegistryError {
constructor(public readonly cwd: string, parseError: unknown) {
let message = `Invalid components.json configuration in ${cwd}.`
if (parseError instanceof z.ZodError) {
message = `Invalid components.json configuration in ${cwd}:\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
super(message, {
code: RegistryErrorCode.INVALID_CONFIG,
cause: parseError,
context: { cwd },
suggestion:
"Check your components.json file for syntax errors or invalid configuration. Run 'npx shadcn@latest init' to regenerate a valid configuration.",
})
this.name = "ConfigParseError"
}
}

View File

@@ -197,3 +197,21 @@ export const configSchema = rawConfigSchema.extend({
// TODO: type the key.
// Okay for now since I don't want a breaking change.
export const workspaceConfigSchema = z.record(configSchema)
export const searchResultItemSchema = z.object({
name: z.string(),
type: z.string().optional(),
description: z.string().optional(),
registry: z.string(),
addCommandArgument: z.string(),
})
export const searchResultsSchema = z.object({
pagination: z.object({
total: z.number(),
offset: z.number(),
limit: z.number(),
hasMore: z.boolean(),
}),
items: z.array(searchResultItemSchema),
})

View File

@@ -1,27 +1,10 @@
import { searchResultItemSchema, searchResultsSchema } from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import fuzzysort from "fuzzysort"
import { z } from "zod"
import { getRegistry } from "./api"
const searchResultItemSchema = z.object({
name: z.string(),
type: z.string().optional(),
description: z.string().optional(),
registry: z.string(),
addCommandArgument: z.string(),
})
const searchResultsSchema = z.object({
pagination: z.object({
total: z.number(),
offset: z.number(),
limit: z.number(),
hasMore: z.boolean(),
}),
items: z.array(searchResultItemSchema),
})
export async function searchRegistries(
registries: string[],
options?: {

View File

@@ -22,7 +22,7 @@ export const DEFAULT_TAILWIND_BASE_COLOR = "slate"
// TODO: Figure out if we want to support all cosmiconfig formats.
// A simple components.json file would be nice.
const explorer = cosmiconfig("components", {
export const explorer = cosmiconfig("components", {
searchPlaces: ["components.json"],
})

17
pnpm-lock.yaml generated
View File

@@ -725,6 +725,9 @@ importers:
cosmiconfig:
specifier: ^9.0.0
version: 9.0.0(typescript@5.9.2)
dedent:
specifier: ^1.6.0
version: 1.6.0
deepmerge:
specifier: ^4.3.1
version: 4.3.1
@@ -4558,6 +4561,14 @@ packages:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
dedent@1.6.0:
resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
babel-plugin-macros:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -9384,7 +9395,7 @@ snapshots:
'@types/node': 20.5.1
chalk: 4.1.2
cosmiconfig: 8.3.6(typescript@5.9.2)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.9.2))(typescript@5.9.2)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.19.10)(typescript@5.9.2))(typescript@5.9.2)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@@ -13379,7 +13390,7 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.9.2))(typescript@5.9.2):
cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.19.10)(typescript@5.9.2))(typescript@5.9.2):
dependencies:
'@types/node': 20.5.1
cosmiconfig: 8.3.6(typescript@5.9.2)
@@ -13543,6 +13554,8 @@ snapshots:
dependencies:
mimic-response: 3.1.0
dedent@1.6.0: {}
deep-eql@5.0.2: {}
deep-extend@0.6.0: {}