Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Fumadocs CLI

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

The Fumadocs CLI (@fumadocs/cli) is an operational tool designed to scaffold projects, manage component installations, customize documentation layouts, and process documentation structures. Operating through the commander package, it offers commands for configuration initialization, component downloads via add, interactive layout modifications via customise, EPUB Book Exporting, and directory tree parsing into MDX or JavaScript components.

Sources: packages/cli/package.json:2-15, packages/cli/src/index.ts:15-106

CLI Architecture and Command Dispatch

Entry Point and Command Registration

The Fumadocs CLI executable is defined in packages/cli/src/index.ts and configured as a Node.js command-line tool via the bin field in packages/cli/package.json. It instantiates a root Command object from commander, reads its version metadata from packageJson.version, and registers global options such as --config <string>. When executed without arguments, the root action invokes initConfig(), which checks for or creates a ./cli.json configuration file and logs status messages using picocolors.

"bin": {
  "cli": "./dist/index.js"
},

Sources: packages/cli/package.json:13-15

const program = new Command().option('--config <string>');
 
program
  .name('fumadocs')
  .description('CLI to setup Fumadocs, init a config')
  .version(packageJson.version)
  .action(async () => {
    if (await initConfig()) {
      console.log(picocolors.green('Initialized a `./cli.json` config file.'));
    } else {
      console.log(picocolors.redBright('A config file already exists.'));
    }
  });

Sources: packages/cli/src/index.ts:15-27

Subcommands are registered directly on the program instance or nested under parent command groups. The available CLI commands, their aliases, arguments, options, and actions are structured as follows:

CommandAliasesArgumentsOptionsAction / Handler
fumadocs (default)——--config <string>Initializes ./cli.json via initConfig().
customisecustomize—--dir <string>, --config <string>Loads config and runs customise with a registry client.
add—[components...]--dir <string>, --config <string>Downloads components using a registry client and config.
export epub——--framework <name>, --output <path>, --scaffold-onlyExports documentation to EPUB format via exportEpub.
tree—[json_or_args], [output]--js, --no-root, --import-name <string>, --config <string>Parses directory trees or JSON nodes into MDX or JS formats.

Sources: packages/cli/src/index.ts:17-106

Registry Connector Resolution and Configuration

Registry source resolution is handled by createClientFromDir, which maps shortcut keywords to predefined remote URLs or distinguishes between HTTP endpoints and local filesystem paths.

const dirShortcuts: Record<string, string> = {
  ':preview': 'https://preview.fumadocs.dev/registry',
  ':dev': 'http://localhost:3000/registry',
};

Sources: packages/cli/src/index.ts:39-42

The shortcut mapping supports the following expansion values:

ShortcutTarget URL
:previewhttps://preview.fumadocs.dev/registry
:devhttp://localhost:3000/registry

Sources: packages/cli/src/index.ts:39-42

Note

If the directory parameter does not match :preview or :dev, createClientFromDir evaluates whether the string begins with http:// or https://. If it does, it instantiates an HttpRegistryConnector; otherwise, it falls back to a LocalRegistryConnector.

function createClientFromDir(dir = 'https://fumadocs.dev/registry') {
  if (dir in dirShortcuts) dir = dirShortcuts[dir];
 
  return dir.startsWith('http://') || dir.startsWith('https://')
    ? new HttpRegistryConnector(dir)
    : new LocalRegistryConnector(dir);
}

Sources: packages/cli/src/index.ts:108-114

Registry Component Installation Flow

Component Selection and Interactive Prompts

The component addition command (add) managed in packages/cli/src/commands/add.ts facilitates downloading and integrating UI elements, layouts, and integration components into a project. When invoked without explicit component arguments (input.length === 0), the CLI displays a loading spinner and fetches registry information using connector.fetchRegistryInfo(subRegistry). It constructs a list of selectable items by scanning across three distinct sources: common components (undefined sub-registry with prefix common), Sanity components (fumadocs/sanity with prefix sanity), and the UI library registry configured by config.uiLibrary (mapped via UIRegistries) with prefix ui.

const subRegistry = UIRegistries[config.uiLibrary];
 
if (input.length === 0) {
  const spin = spinner();
  spin.start('fetching registry');
 
  async function scan(subRegistry?: string, prefix?: string): Promise<AddOption[]> {
    const info = await connector.fetchRegistryInfo(subRegistry);
 
    return info.indexes.map((item) => ({
      label: `${prefix ? `${picocolors.bold(prefix)} - ` : ''}${item.title ?? item.name}`,
      value: { name: item.name, subRegistry },
      hint: item.description,
    }));
  }
 
  spin.stop(picocolors.bold(picocolors.greenBright('registry fetched')));
  const value = await autocompleteMultiselect({
    message: 'Select components to install',
    options: [
      ...(await scan(undefined, 'common')),
      ...(await scan('fumadocs/sanity', 'sanity')),
      ...(await scan(subRegistry, 'ui')),
    ].sort((a, b) => a.label.localeCompare(b.label)),
  });

Sources: packages/cli/src/commands/shared.ts:1-4, packages/cli/src/commands/add.ts:22-46

The UI registries mapping defines the sub-registry paths associated with supported UI libraries:

UI Library KeySub-Registry Path
base-uifumadocs/base-ui
radix-uifumadocs/radix-ui

Sources: packages/cli/src/commands/shared.ts:1-4

Presented through @clack/prompts's autocompleteMultiselect, the sorted options allow users to select multiple components simultaneously. If the user cancels the prompt via isCancel(value), the command outputs an ending message and exits gracefully. When components are provided directly as arguments via the command line, the CLI checks each item against connector.hasComponent(item) to assign the appropriate sub-registry or defaults to the configured UI registry.

  if (isCancel(value)) {
    outro('Ended');
    return;
  }
 
  targets = value;
} else {
  targets = await Promise.all(
    input.map(async (item) =>
      (await connector.hasComponent(item)) ? { name: item } : { subRegistry, name: item },
    ),
  );
}

Sources: packages/cli/src/commands/add.ts:48-60

Warning

If a concurrent installation is attempted while this.interactive is already populated in FumadocsComponentInstaller, an error stating cannot install while installing another component is thrown, preventing race conditions during file writing.

async installInteractive(name: string, subRegistry?: string): Promise<void> {
  if (this.interactive) {
    throw new Error(`cannot install while installing another component`);
  }
 

Sources: packages/cli/src/registry/installer.ts:54-58

Installer Configuration and Layout Preservation

FumadocsComponentInstaller extends ComponentInstaller from fuma-cli/registry/installer, binding output directories to configuration aliases and registering plugins such as pluginPreserveLayouts().

export class FumadocsComponentInstaller extends ComponentInstaller {
  private interactive: {
    name: string;
    spin: SpinnerResult;
  } | null = null;
 
  constructor(connector: RegistryConnector, config: LoadedConfig, cwd?: string) {
    super(connector, {
      cwd,
      framework: config.framework,
      outDir: {
        base: config.baseDir,
        components: config.aliases.componentsDir,
        css: config.aliases.cssDir,
        layout: config.aliases.layoutDir,
        lib: config.aliases.libDir,
        ui: config.aliases.uiDir,
      },
      io: {
        onWarn: (message) => {
          this.interactive?.spin.message(message);
        },
        confirmFileOverride: async (options) => {
          if (!this.interactive) return true;
          const { name, spin } = this.interactive;
          spin.clear();
          const value = await confirm({
            message: `Do you want to override ${options.path}?`,
            initialValue: false,
          });
          if (isCancel(value)) {
            outro('Installation terminated');
            process.exit(0);
          }
          spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${name}`)));
          return value;
        },
        onFileDownloaded: (options) => {
          this.interactive?.spin.message(options.path);
        },
      },
      plugins: [pluginPreserveLayouts()],
    });
  }

Sources: packages/cli/src/registry/installer.ts:9-52

The pluginPreserveLayouts plugin defined in packages/cli/src/registry/plugins/preserve.ts intercepts sub-component trees and import statements to maintain references to built-in layout packages unless a layout itself is being installed directly. The tracked layout components include:

Layout IdentifierMapped Package / Specifier
layouts/homefumadocs-ui/layouts/home
layouts/fluxfumadocs-ui/layouts/flux
layouts/notebookfumadocs-ui/layouts/notebook
layouts/docsfumadocs-ui/layouts/docs
layouts/sharedfumadocs-ui/layouts/shared

Sources: packages/cli/src/registry/plugins/preserve.ts:7-30, packages/cli/src/registry/plugins/preserve.ts:43-50

Note

When downloading files, FumadocsComponentInstaller hooks into confirmFileOverride to prompt the user via @clack/prompts if an existing file conflicts with a downloaded registry asset, terminating the process if cancelled.

confirmFileOverride: async (options) => {
  if (!this.interactive) return true;
  const { name, spin } = this.interactive;
  spin.clear();
  const value = await confirm({
    message: `Do you want to override ${options.path}?`,
    initialValue: false,
  });
  if (isCancel(value)) {
    outro('Installation terminated');
    process.exit(0);
  }
  spin.start(picocolors.bold(picocolors.cyanBright(`Installing ${name}`)));
  return value;
},

Sources: packages/cli/src/registry/installer.ts:31-45

Dependency Resolution and Package Manager Execution

After downloading target components, installInteractive evaluates required dependencies through deps.hasRequired(). If missing dependencies or devDependencies are identified, they are displayed inside a boxed prompt using @clack/prompts.

try {
  this.interactive = { name, spin };
  const deps = await super.install(name, subRegistry).then((res) => res.deps());
  spin.stop(picocolors.bold(picocolors.greenBright(`${name} installed`)));
 
  if (deps.hasRequired()) {
    log.message();
    box([...deps.dependencies, ...deps.devDependencies].join('\n'), 'New Dependencies');
    const pm = (await detectPackageManager())?.name ?? 'npm';

Sources: packages/cli/src/registry/installer.ts:62-70

The CLI detects the local project's package manager via detectPackageManager(), defaulting to npm if no manager is found. Users are prompted to confirm whether they want to install the packages immediately with the detected manager.

const pm = (await detectPackageManager())?.name ?? 'npm';
const value = await confirm({
  message: `Do you want to install with ${pm}?`,
});
 
if (isCancel(value)) {
  outro('Installation terminated');
  process.exit(0);
}
 

Sources: packages/cli/src/registry/installer.ts:70-79

If confirmed, the installer starts a spinner and invokes deps.installRequired(pm) to execute the installation. If declined, it invokes deps.writeRequired() to write declarations without running the package manager.

  if (value) {
    const spin = spinner({
      errorMessage: 'Failed to install dependencies',
    });
    spin.start('Installing dependencies');
    await deps.installRequired(pm);
    spin.stop('Dependencies installed');
  } else {
    await deps.writeRequired();
  }
}

Sources: packages/cli/src/registry/installer.ts:80-90

Interactive Layout and Slot Customization

Layout Selection and Customization Targets

The customise command guides users through interactive selection prompts using @clack/prompts to choose a UI layout and target parts for customization based on the configured UI library registry. The available layout targets and their identifiers are defined with specific integration hints.

Layout OptionIdentifier (id)Target Registry PathDescription
Docs Layoutdocslayouts/docsThe default documentation layout
Notebook Layoutnotebooklayouts/notebookA more compact variant of the docs layout
Flux Layoutfluxlayouts/fluxThe experimental variant of the docs layout
Home Layouthomelayouts/homeThe layout for non-documentation pages

Sources: packages/cli/src/commands/customise.ts:31-89, packages/cli/src/commands/shared.ts:1-4

When a layout other than home is selected, users can choose between installing the entire layout, replacing and rewriting from minimal styles via layouts/docs-min, or customizing specific component slots by parsing unlisted registry indexes matching slots/${selected.id}.

return select<TargetInfo>({
  message: 'Which part do you want to customize?',
  options: [
    {
      label: 'All',
      hint: 'install the entire layout',
      value: selected,
    },
    {
      label: 'Replace & rewrite from minimal styles',
      hint: 'for those who want to build their own UI from ground up',
      value: {
        id: 'docs-min',
        targets: [{ name: 'layouts/docs-min' }],
        print() {
          printLayout(
            ['fumadocs-ui/layouts/docs', '@/layouts/docs'],
            ['fumadocs-ui/layouts/docs/page', '@/layouts/docs/page'],
          );
        },
      },
    },
    ...info.unlistedIndexes.flatMap((index) => {
      const prefix = `slots/${selected.id}`;
      if (!index.name.startsWith(prefix)) return [];
      let name = index.name.slice(prefix.length + 1);
 
      if (name.startsWith('page/')) {
        name = name.slice('page/'.length);
 
        return {
          label: `Page: ${name}`,
          hint: "only replace a part of layout's page, useful for adjusting details",
          value: {
            id: index.name,
            targets: [{ subRegistry, name: index.name }],
            print() {
              printSlot({
                at: `@/layouts/${selected.id}/page/slots/${name}`,
                layoutId: selected.id,
                name,
                isPage: true,
              });
            },
          } as TargetInfo,
        };
      }
 
      return {
        label: `Layout: ${name}`,
        hint: 'only replace a part of layout, useful for adjusting details',
        value: {
          id: index.name,
          targets: [{ subRegistry, name: index.name }],
          print() {
            printSlot({
              at: `@/layouts/${selected.id}/slots/${name}`,
              layoutId: selected.id,
              name,
              isPage: false,
            });
          },
        } as TargetInfo,
      };
    }),
  ],
});

Sources: packages/cli/src/commands/customise.ts:94-160

Note

For non-home layouts, slot customization paths distinguish between page-level parts prefixed with page/ and general layout slots, generating import mapping advice for either <DocsPage /> or <DocsLayout /> components accordingly.

if (name.startsWith('page/')) {
  name = name.slice('page/'.length);
 
  return {
    label: `Page: ${name}`,
    hint: "only replace a part of layout's page, useful for adjusting details",
    value: {
      id: index.name,
      targets: [{ subRegistry, name: index.name }],
      print() {
        printSlot({
          at: `@/layouts/${selected.id}/page/slots/${name}`,
          layoutId: selected.id,
          name,
          isPage: true,
        });
      },
    } as TargetInfo,
  };
}
 
return {
  label: `Layout: ${name}`,
  hint: 'only replace a part of layout, useful for adjusting details',
  value: {
    id: index.name,
    targets: [{ subRegistry, name: index.name }],
    print() {
      printSlot({
        at: `@/layouts/${selected.id}/slots/${name}`,
        layoutId: selected.id,
        name,
        isPage: false,
      });
    },
  } as TargetInfo,
};

Sources: packages/cli/src/commands/customise.ts:121-157

File Tree Inspection and Formatting

Directory Parsing and JsonTreeNode Schema

The tree command processes directory structures by accepting either a raw JSON string argument or scanning a target directory via runTree. The underlying AST nodes are defined by the JsonTreeNode union type, supporting files, directories, reports, and links.

export type JsonTreeNode =
  | {
      type: 'file';
      name: string;
    }
  | {
      type: 'directory';
      name: string;
      contents: JsonTreeNode[];
    }
  | {
      type: 'report';
    }
  | {
      type: 'link';
      name: string;
    };

Sources: packages/cli/src/commands/file-tree.ts:1-18

When parsing inputs, valid nodes are filtered against a known scanned list containing file, directory, and link node types.

Node Type (type)PropertiesDescription
filename: stringRepresents an individual file within the tree
directoryname: string, contents: JsonTreeNode[]Represents a folder containing nested child nodes
linkname: stringRepresents a symbolic or external link node
reportNoneRepresents an execution report or diagnostic entry

Sources: packages/cli/src/commands/file-tree.ts:1-19

Note

If a directory node contains a single nested directory child, the parser flattens the path by combining their names (e.g., item.name/child.name) to avoid rendering redundant single-child nesting levels.

if (item.type === 'directory') {
  if (item.contents.length === 1 && 'name' in item.contents[0]) {
    const child = item.contents[0];
 
    return toNode({
      ...child,
      name: `${item.name}/${child.name}`,
    });
  }

Sources: packages/cli/src/commands/file-tree.ts:27-35

Conversion to MDX and JavaScript Structures

The treeToMdx function recursively transforms JsonTreeNode arrays into MDX component syntax utilizing <Files>, <Folder>, and <File> tags. When the noRoot option is enabled and the root input consists of a single directory, the top-level container is unwrapped so that its internal contents become the primary children.

export function treeToMdx(input: JsonTreeNode[], noRoot = false): string {
  function toNode(item: JsonTreeNode): string {
    if (item.type === 'file' || item.type === 'link') {
      return `<File name=${JSON.stringify(item.name)} />`;
    }
 
    if (item.type === 'directory') {
      if (item.contents.length === 1 && 'name' in item.contents[0]) {
        const child = item.contents[0];
 
        return toNode({
          ...child,
          name: `${item.name}/${child.name}`,
        });
      }
 
      return `<Folder name=${JSON.stringify(item.name)}>
${item.contents.map(toNode).filter(Boolean).join('\n')}
</Folder>`;
    }
 
    return '';
  }
 
  let children = input.filter((v) => scanned.includes(v.type));
 
  if (noRoot && children.length === 1 && input[0].type === 'directory') {
    children = input[0].contents;
  }
 
  return `<Files>
${children.map(toNode).filter(Boolean).join('\n')}
</Files>`;
}

Sources: packages/cli/src/commands/file-tree.ts:21-54

export function treeToJavaScript(
  input: JsonTreeNode[],
  noRoot?: boolean,
  importName = 'fumadocs-ui/components/files',
): string {
  return `import { File, Files, Folder } from ${JSON.stringify(importName)}
 
export default (${treeToMdx(input, noRoot)})`;
}

Sources: packages/cli/src/commands/file-tree.ts:56-64

Warning

The CLI automatically selects JavaScript output format if the --js flag is passed or if the output file path extension matches .js, .tsx, or .jsx; otherwise, it outputs raw MDX components.

const jsExtensions = ['.js', '.tsx', '.jsx'];
const noRoot = !root;
let nodes: JsonTreeNode[];
 
try {
  nodes = JSON.parse(str ?? '') as JsonTreeNode[];
} catch {
  nodes = await runTree(str ?? './');
}
 
const out =
  js || (output && jsExtensions.includes(path.extname(output)))
    ? treeToJavaScript(nodes, noRoot, importName)
    : treeToMdx(nodes, noRoot);

Sources: packages/cli/src/index.ts:84-97

Registry Source Organization and Indexing

UI Library Catalog Structures

Fumadocs manages component registries across different UI packages, including fumadocs/radix-ui, fumadocs/base-ui, and fumadocs/sanity. Each registry defines an export structure containing its registry name, source directory (dir), a catalog of components with associated files and targets, and package dependencies.

export const registry: Registry = {
  name: 'fumadocs/radix-ui',
  dir,
  components: [
    ...commonComponents,
    ...(await findSlotComponents(dir)),
    // ... layout and component definitions
  ],
  dependencies: {
    'fumadocs-core': null,
    'fumadocs-ui': null,
    react: null,
  },
};

Sources: packages/radix-ui/registry/index.ts:9-345

Shared utilities and common components—such as URL handlers, class-name merging, and table-of-contents clerks—are reused across libraries through modular registry shares. Slot components are dynamically discovered using tinyglobby scanning over layout directories.

export async function findSlotComponents(dir: string): Promise<Component[]> {
  const slots: Component[] = [];
 
  for (const file of await glob('layouts/**/slots/*', { cwd: dir })) {
    const relativePath = path.relative('layouts', file);
    const name = relativePath
      .split(path.sep)
      .filter((v) => v !== 'slots')
      .join('/')
      .slice(0, -path.extname(relativePath).length);
 
    slots.push({
      name: `slots/${name}`,
      unlisted: true,
      files: [
        {
          path: file,
          type: 'layout',
          target: path.join('<dir>', relativePath),
        },
      ],
    });
  }
 
  return slots;
}

Sources: packages/shared/registry.ts:5-30

Note

The order of files matters when writing import paths on the consumer's codebase, ensuring that common utilities and slot components precede layout and UI primitives in the registry array.

// in shadcn cli, the order of files matters when writing import paths on consumer's codebase
export const registry: Registry = {
  name: 'fumadocs/radix-ui',
  dir,
  components: [
    ...commonComponents,
    ...(await findSlotComponents(dir)),

Sources: packages/radix-ui/registry/index.ts:8-14

Component Manifest Definitions

Component entries within a registry specify metadata, file types, source paths, and installation targets. Unlisted components, such as low-level UI primitives (button, popover, scroll-area, collapsible) and internal layout variations (layouts/sidebar, layouts/shared, layouts/docs, layouts/flux, layouts/notebook, layouts/home), are omitted from public component listings while remaining installable by the CLI.

Component NameFile TypesTarget/Preserve SettingsDescription / Notes
github-infocomponentsDefault targetA card to display GitHub repo info
image-zoomcomponents, cssDefault targetZoomable Image component with styles
layouts/sharedlayout<dir>/shared/index.tsx, client.tsx (preserveAs: layouts/shared)Unlisted shared layout files
layouts/docslayout<dir>/docs/index.tsx, client.tsx, page/index.tsxUnlisted documentation layout files
ai/page-actionscomponents<dir>/ai/page-actions.tsxCommon page actions for AI search integration

Sources: packages/radix-ui/registry/index.ts:36-339

Warning

Registry entries that include layout files must specify metadata with preserveAs rules to prevent incorrect path rewriting during consumer project scaffolding.

  meta: {
    preserveAs: 'layouts/shared',
  },
},

Sources: packages/radix-ui/registry/index.ts:44-47

Was this page helpful?