diff --git a/.changeset/odd-keys-visit.md b/.changeset/odd-keys-visit.md
new file mode 100644
index 0000000000..21baf3d368
--- /dev/null
+++ b/.changeset/odd-keys-visit.md
@@ -0,0 +1,5 @@
+---
+"shadcn": minor
+---
+
+add shadcn preset commands
diff --git a/apps/v4/components/announcement.tsx b/apps/v4/components/announcement.tsx
index e32fbe83b0..33d1c38481 100644
--- a/apps/v4/components/announcement.tsx
+++ b/apps/v4/components/announcement.tsx
@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
export function Announcement() {
return (
-
- Introducing Sera
+
+ New preset commands
)
diff --git a/apps/v4/content/docs/(root)/cli.mdx b/apps/v4/content/docs/(root)/cli.mdx
index 86f283249e..dbf7019ad4 100644
--- a/apps/v4/content/docs/(root)/cli.mdx
+++ b/apps/v4/content/docs/(root)/cli.mdx
@@ -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]
+
+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 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]
+
+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]
+
+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.
diff --git a/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx b/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx
new file mode 100644
index 0000000000..f34d45cb42
--- /dev/null
+++ b/apps/v4/content/docs/changelog/2026-04-preset-commands.mdx
@@ -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.
+
+
diff --git a/packages/shadcn/src/commands/info.ts b/packages/shadcn/src/commands/info.ts
index 6fca34c483..a23026d014 100644
--- a/packages/shadcn/src/commands/info.ts
+++ b/packages/shadcn/src/commands/info.ts
@@ -191,58 +191,7 @@ export function printInfo(data: Awaited>) {
})
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>) {
logger.break()
}
+export function printPresetInfo(
+ preset: Awaited>["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) {
- 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}`)
}
diff --git a/packages/shadcn/src/commands/preset.test.ts b/packages/shadcn/src/commands/preset.test.ts
new file mode 100644
index 0000000000..c9115165bd
--- /dev/null
+++ b/packages/shadcn/src/commands/preset.test.ts
@@ -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()
+ 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>
+ )
+ vi.mocked(getProjectInfo).mockResolvedValue(
+ {} as Awaited>
+ )
+ 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>
+ })
+
+ 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}`)
+ })
+}
diff --git a/packages/shadcn/src/commands/preset.ts b/packages/shadcn/src/commands/preset.ts
new file mode 100644
index 0000000000..b3e237f75c
--- /dev/null
+++ b/packages/shadcn/src/commands/preset.ts
@@ -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 & {
+ chartColor: NonNullable
+}
+
+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("", "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("", "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("", "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 ",
+ "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)
+}
diff --git a/packages/shadcn/src/index.ts b/packages/shadcn/src/index.ts
index 31b45b46bf..c0e9b6ca4a 100644
--- a/packages/shadcn/src/index.ts
+++ b/packages/shadcn/src/index.ts
@@ -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()
diff --git a/packages/tests/src/tests/init.test.ts b/packages/tests/src/tests/init.test.ts
index 2497efb512..7c8abc47cc 100644
--- a/packages/tests/src/tests/init.test.ts
+++ b/packages/tests/src/tests/init.test.ts
@@ -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"))
diff --git a/skills/shadcn/SKILL.md b/skills/shadcn/SKILL.md
index 2aab0b0711..016f824d17 100644
--- a/skills/shadcn/SKILL.md
+++ b/skills/shadcn/SKILL.md
@@ -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 ` for existing projects, or `npx shadcn@latest init --preset ` when initializing.
+- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode `, `preset url `, or `preset open `. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
+- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply ` for existing projects, or `npx shadcn@latest init --preset ` 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 `. Overwrites detected components, fonts, and CSS variables.
- - **Partial**: `npx shadcn@latest apply --preset --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 `. Use `preset url ` or `preset open ` to share or open the preset builder.
+ - **Overwrite**: `npx shadcn@latest apply `. Overwrites detected components, fonts, and CSS variables.
+ - **Partial**: `npx shadcn@latest apply --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 --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 --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 ` 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