feat: add preset commands (#10530)

* feat(cli): add preset commands

* docs(skill): update preset command guidance

* docs(cli): document preset commands

* chore: changeset

* fix(cli): refine preset command output

* fix(cli): align preset decode output

* fix(cli): update preset output fields

* docs(changelog): add preset commands entry

* docs(changelog): show preset command output

* docs(changelog): clarify preset resolve examples

* docs(changelog): refine preset examples

* docs(changelog): add preset command sections

* docs(changelog): show preset resolve output

* docs(changelog): clarify preset open example

* docs(changelog): update preset resolve example

* docs: update preset announcement

* docs: link preset announcement to changelog

* test: increase next init timeout
This commit is contained in:
shadcn
2026-04-28 20:43:16 +04:00
committed by GitHub
parent 68a69d81f7
commit ea6086cbcc
10 changed files with 1003 additions and 67 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add shadcn preset commands

View File

@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
export function Announcement() {
return (
<Badge asChild variant="secondary" className="bg-muted">
<Link href="/sera">
Introducing Sera <ArrowRightIcon />
<Link href="/docs/changelog">
New preset commands <ArrowRightIcon />
</Link>
</Badge>
)

View File

@@ -92,13 +92,13 @@ Options:
Use the `apply` command to apply a preset to an existing project.
```bash
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
```
You can apply only the theme or fonts from a preset without reinstalling UI components:
```bash
npx shadcn@latest apply --preset a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only theme
```
Supported values for `--only` are `theme` and `font`.
@@ -124,6 +124,110 @@ Options:
---
## preset
Use the `preset` command to inspect preset codes and resolve the preset for an existing project.
```bash
npx shadcn@latest preset decode a2r6bw
```
### preset decode
Use `preset decode` to decode a preset code.
```bash
npx shadcn@latest preset decode a2r6bw
```
**Options**
```bash
Usage: shadcn preset decode [options] <code>
decode a preset code
Arguments:
code the preset code to decode
Options:
--json output as JSON. (default: false)
-h, --help display help for command
```
### preset resolve
Use `preset resolve` to resolve the preset from the current project.
```bash
npx shadcn@latest preset resolve
```
The `preset info` command is an alias for `preset resolve`:
```bash
npx shadcn@latest preset info
```
**Options**
```bash
Usage: shadcn preset resolve|info [options]
resolve a preset from your project
Options:
-c, --cwd <cwd> the working directory. defaults to the current directory.
--json output as JSON. (default: false)
-h, --help display help for command
```
### preset url
Use `preset url` to print the create URL for a preset code.
```bash
npx shadcn@latest preset url a2r6bw
```
**Options**
```bash
Usage: shadcn preset url [options] <code>
get the create URL for a preset code
Arguments:
code the preset code
Options:
-h, --help display help for command
```
### preset open
Use `preset open` to open a preset code in the browser.
```bash
npx shadcn@latest preset open a2r6bw
```
**Options**
```bash
Usage: shadcn preset open [options] <code>
open a preset code in the browser
Arguments:
code the preset code
Options:
-h, --help display help for command
```
---
## view
Use the `view` command to view items from the registry before installing them.

View File

@@ -0,0 +1,93 @@
---
title: April 2026 - shadcn preset
description: Decode, share, open, and resolve preset codes from the shadcn CLI.
date: 2026-04-28
---
We added `shadcn preset` commands for working with preset codes.
## Decode a preset
You can decode a preset code to see exactly what it contains:
```bash
npx shadcn@latest preset decode b5owWMfJ8l
```
```txt
Preset
code b5owWMfJ8l
version b
style mira
baseColor mauve
theme mauve
chartColor amber
iconLibrary hugeicons
font inter
fontHeading oxanium
radius large
menuAccent subtle
menuColor inverted-translucent
url https://ui.shadcn.com/create?preset=b5owWMfJ8l
```
## Resolve from a project
Use `preset resolve` in an existing project to see the preset that matches your current configuration.
```bash
npx shadcn@latest preset resolve
```
```txt
Preset
code b5Kc6P0Vc
version b
style luma
baseColor olive
theme lime
chartColor sky
iconLibrary hugeicons
font geist
fontHeading inherit
radius default
menuAccent subtle
menuColor default
url https://ui.shadcn.com/create?preset=b5Kc6P0Vc
```
It works with monorepos too:
```bash
npx shadcn@latest preset resolve -c apps/web
```
## Share or open
Use `preset url` when you need a shareable link:
```bash
npx shadcn@latest preset url b5owWMfJ8l
```
```txt
https://ui.shadcn.com/create?preset=b5owWMfJ8l
```
Use `preset open` to open the preset on shadcn/create for customization:
```bash
npx shadcn@latest preset open b5owWMfJ8l
```
```txt
Opening https://ui.shadcn.com/create?preset=b5owWMfJ8l in your browser.
```
This makes presets easier to inspect, share, and hand off to coding agents without manually decoding codes or building URLs.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -191,58 +191,7 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
})
logger.break()
logger.log(highlighter.info("Preset"))
if (!data.preset?.code) {
printEntries({
"--preset": "-",
})
} else {
const fallbacks = data.preset.fallbacks ?? []
const formatPresetValue = (key: string, value: string | undefined) => {
const suffix = fallbacks.includes(key) ? "*" : ""
return `${value ?? "-"}${suffix}`
}
printEntries({
"--preset": data.preset.code,
url: `${SHADCN_URL}/create?preset=${data.preset.code}`,
style: data.preset.values?.style ?? "-",
baseColor: formatPresetValue(
"baseColor",
data.preset.values?.baseColor
),
theme: formatPresetValue("theme", data.preset.values?.theme),
chartColor: formatPresetValue(
"chartColor",
data.preset.values?.chartColor
),
iconLibrary: formatPresetValue(
"iconLibrary",
data.preset.values?.iconLibrary
),
font: formatPresetValue("font", data.preset.values?.font),
fontHeading: formatPresetValue(
"fontHeading",
data.preset.values?.fontHeading
),
radius: formatPresetValue("radius", data.preset.values?.radius),
menuAccent: formatPresetValue(
"menuAccent",
data.preset.values?.menuAccent
),
menuColor: formatPresetValue(
"menuColor",
data.preset.values?.menuColor
),
})
if (fallbacks.length > 0) {
logger.log("")
logger.log(
" * Uses preset defaults for values not available as options on shadcn/create."
)
}
}
printPresetInfo(data.preset)
// Aliases.
logger.break()
@@ -296,8 +245,54 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
logger.break()
}
export function printPresetInfo(
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
options: {
fallbackNote?: string
} = {}
) {
logger.log(highlighter.info("Preset"))
if (!preset?.code) {
printEntries({
code: "-",
})
} else {
const fallbacks = preset.fallbacks ?? []
const formatPresetValue = (key: string, value: string | undefined) => {
const suffix = fallbacks.includes(key) ? "*" : ""
return `${value ?? "-"}${suffix}`
}
printEntries({
code: preset.code,
version: preset.code[0],
style: preset.values?.style ?? "-",
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
theme: formatPresetValue("theme", preset.values?.theme),
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
font: formatPresetValue("font", preset.values?.font),
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
radius: formatPresetValue("radius", preset.values?.radius),
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
url: `${SHADCN_URL}/create?preset=${preset.code}`,
})
if (fallbacks.length > 0) {
logger.log("")
logger.log(
options.fallbackNote ??
" * Uses preset defaults for values not available as options on shadcn/create."
)
}
}
}
function printEntries(entries: Record<string, string>) {
const maxKeyLength = Math.max(...Object.keys(entries).map((k) => k.length))
const maxKeyLength = Math.max(
...Object.keys(entries).map((key) => key.length)
)
for (const [key, value] of Object.entries(entries)) {
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
}

View File

@@ -0,0 +1,492 @@
import { existsSync } from "fs"
import path from "path"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { logger } from "@/src/utils/logger"
import open from "open"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
decodePresetCode,
getPresetUrl,
openCommand,
preset as presetCommand,
printPresetDecode,
resolve as resolveCommand,
url,
} from "./preset"
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>()
return {
...actual,
existsSync: vi.fn(),
}
})
vi.mock("open", () => ({
default: vi.fn(),
}))
vi.mock("@/src/preset/resolve", () => ({
resolveProjectPreset: vi.fn(),
}))
vi.mock("@/src/utils/get-config", () => ({
getBase: vi.fn(() => "radix"),
getConfig: vi.fn(),
}))
vi.mock("@/src/utils/get-monorepo-info", () => ({
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn(),
isMonorepoRoot: vi.fn(),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn(),
getProjectInfo: vi.fn(),
}))
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
info: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
}))
describe("preset commands", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(isMonorepoRoot).mockResolvedValue(false)
vi.mocked(getMonorepoTargets).mockResolvedValue([])
vi.mocked(getConfig).mockResolvedValue(
{} as Awaited<ReturnType<typeof getConfig>>
)
vi.mocked(getProjectInfo).mockResolvedValue(
{} as Awaited<ReturnType<typeof getProjectInfo>>
)
vi.mocked(resolveProjectPreset).mockResolvedValue({
code: "b123",
fallbacks: [],
values: {
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
})
it("decodes current preset codes", () => {
const result = decodePresetCode("b0")
expect(result).toEqual({
code: "b0",
version: "b",
values: {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
},
derived: [],
url: getPresetUrl("b0"),
})
})
it("applies compatibility values for older preset codes", () => {
const result = decodePresetCode("a0")
expect(result.version).toBe("a")
expect(result.values.chartColor).toBe("blue")
expect(result.derived).toEqual(["chartColor"])
})
it("prints compatibility markers in human output", () => {
printPresetDecode(decodePresetCode("a0"))
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(logger.log).toHaveBeenCalledWith(" code a0")
expect(logger.log).toHaveBeenCalledWith(" version a")
expect(logger.log).toHaveBeenCalledWith(" chartColor blue*")
expect(logger.log).toHaveBeenCalledWith(
` url ${getPresetUrl("a0")}`
)
expect(logger.log).toHaveBeenCalledWith(
" * Compatibility value for older preset versions."
)
})
it("matches decoded preset output ordering to resolved preset output", () => {
printPresetDecode(decodePresetCode("b0"))
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(vi.mocked(logger.log).mock.calls.map((call) => call[0])).toEqual([
"Preset",
" code b0",
" version b",
" style nova",
" baseColor neutral",
" theme neutral",
" chartColor neutral",
" iconLibrary lucide",
" font inter",
" fontHeading inherit",
" radius default",
" menuAccent subtle",
" menuColor default",
` url ${getPresetUrl("b0")}`,
])
})
it("prints decoded preset JSON with derived values", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await presetCommand.parseAsync(["decode", "a0", "--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
code: "a0",
version: "a",
values: {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "blue",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
},
derived: ["chartColor"],
url: getPresetUrl("a0"),
})
log.mockRestore()
})
it("rejects invalid codes", () => {
expect(() => decodePresetCode("c0")).toThrow("Invalid preset code: c0")
})
it("rejects URLs", () => {
expect(() =>
decodePresetCode("https://ui.shadcn.com/create?preset=a0")
).toThrow("Invalid preset code: https://ui.shadcn.com/create?preset=a0")
})
it("prints the create URL from preset url", async () => {
await url.parseAsync(["a0"], { from: "user" })
expect(logger.break).not.toHaveBeenCalled()
expect(logger.log).toHaveBeenCalledWith(getPresetUrl("a0"))
})
it("prints the create URL before opening it", async () => {
vi.mocked(open).mockImplementation(async () => {
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
return {} as Awaited<ReturnType<typeof open>>
})
await openCommand.parseAsync(["a0"], { from: "user" })
expect(logger.break).toHaveBeenCalledTimes(2)
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
expect(open).toHaveBeenCalledWith(getPresetUrl("a0"))
})
it("does not open invalid preset codes", async () => {
const exit = mockProcessExit()
await expect(
openCommand.parseAsync(["https://ui.shadcn.com/create?preset=a0"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(logger.error).toHaveBeenCalledWith(
"Invalid preset code: https://ui.shadcn.com/create?preset=a0"
)
expect(open).not.toHaveBeenCalled()
exit.mockRestore()
})
it("fails when opening the browser fails", async () => {
const exit = mockProcessExit()
vi.mocked(open).mockRejectedValue(new Error("open failed"))
await expect(
openCommand.parseAsync(["a0"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
expect(logger.error).toHaveBeenCalledWith(
"Failed to open preset URL: open failed"
)
exit.mockRestore()
})
it("resolves a project preset using the existing resolver", async () => {
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(logger.log).toHaveBeenCalledWith(" code b123")
expect(logger.log).toHaveBeenCalledWith(" version b")
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("b123"))
expect(logger.log).toHaveBeenCalledWith(
expect.stringContaining("https://ui.shadcn.com/create?preset=b123")
)
expect(resolveProjectPreset).toHaveBeenCalledWith({}, {})
})
it("prints fallback markers when resolving a project preset", async () => {
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: "b123",
fallbacks: ["theme"],
values: {
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("neutral*"))
expect(logger.log).toHaveBeenCalledWith(
" * Uses preset defaults for values not available as options on shadcn/create."
)
})
it("prints the resolved preset as JSON", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
code: "b123",
fallbacks: [],
values: {
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
log.mockRestore()
})
it("prints fallback metadata in resolved preset JSON", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: "b123",
fallbacks: ["theme"],
values: {
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
fallbacks: ["theme"],
values: {
theme: "neutral",
},
})
log.mockRestore()
})
it("supports preset info as an alias for preset resolve", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await presetCommand.parseAsync(["info", "--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
code: "b123",
})
log.mockRestore()
})
it("resolves presets from the provided cwd", async () => {
await resolveCommand.parseAsync(["--cwd", "apps/web"], { from: "user" })
expect(getConfig).toHaveBeenCalledWith(path.resolve("apps/web"))
expect(getProjectInfo).toHaveBeenCalledWith(path.resolve("apps/web"))
})
it("prints no-config output when no components.json exists", async () => {
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(getConfig).mockResolvedValueOnce(null)
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith("No components.json found.")
})
it("prints null JSON when no components.json exists", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(getConfig).mockResolvedValueOnce(null)
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
log.mockRestore()
})
it("prints null JSON when no preset can be resolved", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: null,
fallbacks: [],
values: null,
})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
log.mockRestore()
})
it("matches info monorepo-root behavior", async () => {
const exit = mockProcessExit()
const targets = [{ name: "apps/web" }]
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
await expect(
resolveCommand.parseAsync([], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(formatMonorepoMessage).toHaveBeenCalledWith(
"preset resolve",
targets
)
expect(getConfig).not.toHaveBeenCalled()
exit.mockRestore()
})
it("prints monorepo-root JSON for preset resolve --json", async () => {
const exit = mockProcessExit()
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const targets = [{ name: "apps/web" }]
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
await expect(
resolveCommand.parseAsync(["--json"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
error: "monorepo_root",
message:
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
targets: ["apps/web"],
})
expect(formatMonorepoMessage).not.toHaveBeenCalled()
expect(getConfig).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("prints help when no preset subcommand is provided", async () => {
const outputHelp = vi
.spyOn(presetCommand, "outputHelp")
.mockImplementation(() => {})
await presetCommand.parseAsync([], { from: "user" })
expect(outputHelp).toHaveBeenCalledOnce()
outputHelp.mockRestore()
})
it("errors on unknown preset subcommands", async () => {
const exit = mockProcessExit()
const outputHelp = vi
.spyOn(presetCommand, "outputHelp")
.mockImplementation(() => {})
const writeErr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true)
await expect(
presetCommand.parseAsync(["bogus"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(outputHelp).not.toHaveBeenCalled()
expect(writeErr.mock.calls.join("")).toContain("too many arguments")
writeErr.mockRestore()
outputHelp.mockRestore()
exit.mockRestore()
})
})
function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
})
}

View File

@@ -0,0 +1,231 @@
import { existsSync } from "fs"
import path from "path"
import { printPresetInfo } from "@/src/commands/info"
import {
decodePreset,
V1_CHART_COLOR_MAP,
type PresetConfig,
} from "@/src/preset/preset"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { SHADCN_URL } from "@/src/registry/constants"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import open from "open"
type PresetValues = Omit<PresetConfig, "chartColor"> & {
chartColor: NonNullable<PresetConfig["chartColor"]>
}
type PresetDecodeResult = {
code: string
version: string
values: PresetValues
derived: string[]
url: string
}
export function getPresetUrl(code: string) {
return `${SHADCN_URL}/create?preset=${code}`
}
export function decodePresetCode(code: string): PresetDecodeResult {
const decoded = decodePreset(code)
if (!decoded) {
throw new Error(`Invalid preset code: ${code}`)
}
const derived: string[] = []
const chartColor = (decoded.chartColor ??
V1_CHART_COLOR_MAP[decoded.theme] ??
decoded.theme) as PresetValues["chartColor"]
if (!decoded.chartColor) {
derived.push("chartColor")
}
return {
code,
version: code[0],
values: {
...decoded,
chartColor,
},
derived,
url: getPresetUrl(code),
}
}
export function printPresetDecode(result: PresetDecodeResult) {
printPresetInfo(
{
code: result.code,
fallbacks: result.derived,
values: result.values,
},
{
fallbackNote: " * Compatibility value for older preset versions.",
}
)
}
export const decode = new Command()
.name("decode")
.description("decode a preset code")
.argument("<code>", "the preset code to decode")
.option("--json", "output as JSON.", false)
.action((code, opts) => {
try {
const result = decodePresetCode(code)
if (opts.json) {
console.log(
JSON.stringify(
{
code: result.code,
version: result.version,
values: result.values,
derived: result.derived,
url: result.url,
},
null,
2
)
)
return
}
printPresetDecode(result)
} catch (error) {
handlePresetError(error)
}
})
export const url = new Command()
.name("url")
.description("get the create URL for a preset code")
.argument("<code>", "the preset code")
.action((code) => {
try {
logger.log(decodePresetCode(code).url)
} catch (error) {
handlePresetError(error)
}
})
export const openCommand = new Command()
.name("open")
.description("open a preset code in the browser")
.argument("<code>", "the preset code")
.action(async (code) => {
let presetUrl: string
try {
presetUrl = decodePresetCode(code).url
} catch (error) {
handlePresetError(error)
return
}
logger.break()
logger.log(` Opening ${presetUrl} in your browser.`)
logger.break()
try {
await open(presetUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
handlePresetError(new Error(`Failed to open preset URL: ${message}`))
}
})
export const resolve = new Command()
.name("resolve")
.alias("info")
.description("resolve a preset from your project")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("--json", "output as JSON.", false)
.action(async (opts) => {
try {
const cwd = path.resolve(opts.cwd)
if (
!existsSync(path.resolve(cwd, "components.json")) &&
(await isMonorepoRoot(cwd))
) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
if (opts.json) {
console.log(
JSON.stringify(
{
error: "monorepo_root",
message:
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
targets: targets.map((target) => target.name),
},
null,
2
)
)
} else {
formatMonorepoMessage("preset resolve", targets)
}
process.exit(1)
}
}
const config = await getConfig(cwd)
if (!config) {
if (opts.json) {
console.log(JSON.stringify(null, null, 2))
return
}
logger.log("No components.json found.")
return
}
const projectInfo = await getProjectInfo(cwd)
const preset = await resolveProjectPreset(config, projectInfo)
if (opts.json) {
console.log(JSON.stringify(preset.code ? preset : null, null, 2))
return
}
printPresetInfo(preset)
} catch (error) {
handleError(error)
}
})
export const preset = new Command()
.name("preset")
.description("manage presets")
.addCommand(decode)
.addCommand(resolve)
.addCommand(url)
.addCommand(openCommand)
.action(() => {
preset.outputHelp()
})
function handlePresetError(error: unknown) {
if (error instanceof Error) {
logger.error(error.message)
}
process.exit(1)
}

View File

@@ -8,6 +8,7 @@ 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 { preset } from "@/src/commands/preset"
import { registry } from "@/src/commands/registry"
import { search } from "@/src/commands/search"
import { view } from "@/src/commands/view"
@@ -40,6 +41,7 @@ async function main() {
.addCommand(info)
.addCommand(build)
.addCommand(mcp)
.addCommand(preset)
.addCommand(registry)
program.parse()

View File

@@ -13,7 +13,7 @@ import { createRegistryServer } from "../utils/registry"
describe("shadcn init - next-app", () => {
it("should init with default configuration", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
await npxShadcn(fixturePath, ["init", "--defaults"], { timeout: 120000 })
const componentsJsonPath = path.join(fixturePath, "components.json")
expect(await fs.pathExists(componentsJsonPath)).toBe(true)
@@ -54,7 +54,9 @@ describe("shadcn init - next-app", () => {
it("should init without CSS variables", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"])
await npxShadcn(fixturePath, ["init", "--defaults", "--no-css-variables"], {
timeout: 120000,
})
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
@@ -64,7 +66,9 @@ describe("shadcn init - next-app", () => {
it("should init with components", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults", "button"])
await npxShadcn(fixturePath, ["init", "--defaults", "button"], {
timeout: 120000,
})
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))

View File

@@ -77,7 +77,8 @@ These rules are **always enforced**. Each links to a file with Incorrect/Correct
### CLI
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest apply --preset <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
@@ -150,6 +151,7 @@ The injected project context contains these key fields:
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
See [cli.md — `info` command](./cli.md) for the full field reference.
@@ -174,8 +176,10 @@ npx shadcn@latest docs button dialog select
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply --preset <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
@@ -208,11 +212,17 @@ npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply --preset a2r6bw --only theme
npx shadcn@latest apply --preset a2r6bw --only font
npx shadcn@latest apply --preset a2r6bw --only theme,font
npx shadcn@latest apply a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only font
npx shadcn@latest apply a2r6bw --only theme,font
# Inspect preset codes and project preset state.
npx shadcn@latest preset decode a2r6bw
npx shadcn@latest preset url a2r6bw
npx shadcn@latest preset open a2r6bw
npx shadcn@latest preset resolve
npx shadcn@latest preset resolve --json
# Add components.
npx shadcn@latest add button card dialog