mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-29 23:55:02 +00:00
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:
5
.changeset/chatty-windows-stop.md
Normal file
5
.changeset/chatty-windows-stop.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": major
|
||||
---
|
||||
|
||||
add new mcp server and command
|
||||
@@ -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",
|
||||
|
||||
23
packages/shadcn/src/commands/mcp.ts
Normal file
23
packages/shadcn/src/commands/mcp.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ const searchOptionsSchema = z.object({
|
||||
|
||||
export const search = new Command()
|
||||
.name("search")
|
||||
.alias("list")
|
||||
.description("search items from registries")
|
||||
.argument(
|
||||
"<registries...>",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
132
packages/shadcn/src/mcp/utils.ts
Normal file
132
packages/shadcn/src/mcp/utils.ts
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
17
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user