eoria

CLI and registry

The eoria CLI, what each command touches, and how the registry stays shadcn-compatible.

Eleven commands, shipped as @eoria/cli with an eoria binary. Run them with npx @eoria/cli, or add the package as a dev dependency and call pnpm eoria. All of them are file plumbing over a static registry, so there is nothing to log in to and nothing that phones home.

init, add and extend write files. diff, list, search, view, info, docs and doctor only read, unless you pass doctor --fix. mcp hands the read commands and add to an agent as tools, and mcp init writes one config file. Every command that talks to the registry takes --registry <url or folder> to use another one for a single run. list, search, view, info and doctor take --json, which prints one JSON object to stdout and nothing else.

init

Terminal window
npx @eoria/cli init

Writes eoria.json next to your package.json, then sets up what Unistyles needs:

  • src/unistyles.ts, which registers the light and dark themes, and an import '@/unistyles' line at the top of your root layout. It looks for app/_layout.tsx, App.tsx and index.js under the source root and the project root, and tells you if it finds none.
  • babel.config.js with the Unistyles and Worklets plugins, if you have no Babel config yet. If you do, it prints the two plugin lines for you to paste. An existing unistyles.ts is reported and kept.
  • A @/* path alias in tsconfig.json, if the alias is missing.
  • The peer packages, through expo install on Expo projects and your package manager otherwise. Pass --no-install to only print the command.

The CLI picks the package manager from the packageManager field in package.json, then from the lockfile (pnpm-workspace.yaml also counts as pnpm). It looks in the app folder first, then in each folder above it, and stops at the first answer or at the folder that holds .git. So an app inside a pnpm workspace installs with pnpm even though the lockfile sits at the workspace root. With no answer it uses npm. On Expo projects the CLI passes its choice to expo install as --pnpm, --yarn, --bun or --npm. Expo’s own lookup only reads lockfiles and falls back to npm, so a new workspace without a lockfile would otherwise install with npm.

If the install fails, init exits with 1. The error lists the files it already wrote, which it leaves in place, and the install command to run once you fix the cause.

If a shadcn components.json exists, init reads its aliases.ui so the components land in the folder you already use.

add

Terminal window
npx @eoria/cli add button select

Fetches each item from the registry, follows registryDependencies until nothing is missing, and writes the files in dependency order. button brings text. select brings popper, portal and text. Imports inside the copied files are rewritten from @/components/ui to your alias.

A file that already exists is left alone and reported. add records a hash of every file it writes, so it can tell “you changed this” from “it was already there”. Files it skipped get no hash, so diff will not pretend to know what they looked like. Pass --overwrite to replace either. npm dependencies the items declare are installed unless you pass --no-install. A failed install makes add exit with 1, after the files are written. The error names them and prints the install command to run again.

Terminal window
npx @eoria/cli add button --dry-run
npx @eoria/cli add button --diff

Both flags write nothing, eoria.json included. --dry-run resolves everything and lists each file as would write, would skip or would overwrite, then prints the install command it would run. --diff prints the hunks between each file you already have and the registry file, in the format diff uses, and marks the rest as new files. Lines that start with - are yours.

Blocks

Terminal window
npx @eoria/cli add sign-in

A block is a whole screen built from components, such as a sign-in form. You add one by name, the same way you add a component, and add brings the components it imports. The registry marks a block as registry:block and a component as registry:ui.

Block files go in their own folder. By default that is the blocks folder next to your components directory, so src/components/ui gives src/components/blocks/sign-in.tsx, and src/ui gives src/blocks/sign-in.tsx. Set blocks in eoria.json to put them somewhere else, for example "blocks": "src/screens". init does not ask for it, and add creates the folder with the first block. A block imports its components through your alias, so it compiles wherever it lands.

diff, view, info and doctor treat a block like any other item. After you add one the file is yours to edit, and diff shows what you changed.

diff

Terminal window
npx @eoria/cli diff
npx @eoria/cli diff button

Compares each installed item with the registry and prints changed hunks. Every file gets one of five labels: up to date, changed in the registry, edited locally, both, or missing. The hash from add is what makes the distinction possible. --full prints whole files.

diff also scans your components folder for files that import an item, such as a checkout-button.tsx that extends button. When the base changed upstream, it names those files, because they inherit the change through extendSlotRecipe the moment you update.

extend

Terminal window
npx @eoria/cli extend button checkout-button

Writes checkout-button.tsx next to button.tsx. The file imports the base recipe, calls extendSlotRecipe on it with empty slots and variants for you to fill, and exports CheckoutButton from the base’s createButton factory. Bases without a factory get a comment explaining the two options instead. See Recipes for the merge rules.

Terminal window
npx @eoria/cli list
npx @eoria/cli search menu

list prints every registry item with its name, type, title and description. The type is ui for a component and block for a block. Inside a project, installed items get a ✓. Outside one it reads the default registry, so you can browse before running init.

search matches the query against name, title and description, ignoring case. Every word of the query has to appear somewhere in the item. Name matches come first, then title matches, then description matches.

Terminal window
npx @eoria/cli list --type block
npx @eoria/cli search sign --type block

--type ui or --type block keeps one type of item, in both commands.

With --json, list prints { registry, items } and search prints { registry, query, items }. Each item has name, title, description, type and installed, which is null outside a project. type is the registry value, registry:ui or registry:block.

view

Terminal window
npx @eoria/cli view select
npx @eoria/cli view select --source

Prints one item’s title, description, npm dependencies and registry dependencies, then each file with the path add would write it to. The add also brings line follows the registry dependencies all the way down, which for select gives portal, popper and text. --source appends the file contents, with imports rewritten to your alias.

In --json output the direct lists are dependencies and registryDependencies, and the followed ones are allDependencies and allRegistryDependencies. Each entry of files has path, type, target and localPath, plus content when you pass --source.

docs

Terminal window
npx @eoria/cli docs select
npx @eoria/cli docs theming
npx @eoria/cli docs sign-in
npx @eoria/cli docs

Prints the Markdown version of a docs page to stdout. docs tries a bare topic as a component first, so select fetches /components/select.md. If that page does not exist it tries the other sections in turn: /start, /blocks, /guides and /agents. So theming fetches /start/theming.md and sign-in fetches /blocks/sign-in.md. A topic with a slash, such as start/theming, names the page outright. With no topic you get /llms.txt, the index of every page.

The docs URL is the registry URL without its trailing /r. A local registry folder has no docs site, so docs stops and asks for --docs-url, which takes a URL or the folder of a local docs build. When no page answers, the error names every URL it tried.

info

Terminal window
npx @eoria/cli info
npx @eoria/cli info --json

Prints what the CLI can see of the project. That is the CLI version, where eoria.json lives and what it says, the registry in use, the package manager, whether the app uses Expo, and the version ranges of @eoria/core, react-native-unistyles, react-native-reanimated and react-native-worklets from your package.json. Every file of every installed item gets one of the five diff labels. Paste the output into a bug report, or give --json to an agent before it touches your components.

{
"cli": { "version": "0.1.1" },
"root": "/work/app",
"config": {
"path": "/work/app/eoria.json",
"error": null,
"values": {
"registry": "https://eoria.adamtrip.pt/r",
"components": "src/components/ui",
"alias": "@/components/ui"
}
},
"registry": {
"location": "https://eoria.adamtrip.pt/r",
"source": "eoria.json",
"remote": true,
"reachable": true,
"error": null
},
"components": { "directory": "src/components/ui", "alias": "@/components/ui", "exists": true },
"blocks": { "directory": "src/components/blocks", "exists": false },
"installed": [
{
"name": "button",
"files": [
{ "path": "src/components/ui/button.tsx", "status": "local", "label": "edited locally" }
]
}
],
"packageManager": "pnpm",
"expo": true,
"packages": {
"@eoria/core": "^0.1.0",
"react-native-unistyles": "^3.0.0",
"react-native-reanimated": "~4.1.0",
"react-native-worklets": null
}
}

The keys keep their names and types between releases, and new ones may appear. status is unchanged, upstream, local, both or missing, the five diff labels in the order the diff section lists them, and label is the text diff prints. A sixth value, unknown, means the registry could not supply the item, so info could only check the file against its hash. registry.source is flag, eoria.json or default. packageManager is pnpm, yarn, bun or npm, found the way init finds it. blocks.directory is where block files go, and blocks.exists stays false until you add the first block. Outside a project config.path, config.values, components and blocks are null and installed is empty. A package that package.json does not list is null.

doctor

Terminal window
npx @eoria/cli doctor
npx @eoria/cli doctor --fix

Checks the setup init and add leave behind. Each check reports pass, warn or fail, with one line on what to do about it. The exit code is 1 when any check fails and 0 otherwise, so it works as a CI step. Warnings do not change the exit code. The checks run in this order, and the names are the id values in the JSON:

  • config. eoria.json exists and parses. If it does not, doctor stops here.
  • registry. The registry index loads.
  • components-folder. The components folder exists.
  • alias. tsconfig.json has a path for the alias prefix, such as @/*. A project without a tsconfig.json gets a warning.
  • unistyles-file. unistyles.ts exists under the source root.
  • theme-import. The root layout init looks for imports it. If doctor finds no layout, it warns.
  • babel. babel.config.js lists react-native-unistyles/plugin and react-native-worklets/plugin. Reanimated wants the Worklets plugin last, which is how init writes it, so any other order is a warning. From Expo SDK 54, babel-preset-expo adds the Worklets plugin itself when react-native-worklets is installed and runs it after your own plugins. So a config with only the Unistyles plugin passes when all of these hold: the project is on SDK 54 or later, the file names babel-preset-expo or expo, it names no React Native preset (@react-native/babel-preset or metro-react-native-babel-preset), and no worklets: false or reanimated: false appears anywhere in it. doctor does not parse the file, so a comment that says worklets: false counts too. Otherwise it asks you to list the Worklets plugin, which is always valid. The SDK comes from the installed expo package, or else from the range in package.json, where only a single range starting at 54 or later counts, such as ~54.0.0.
  • peers. package.json lists @eoria/core and every peer init installs.
  • installed-files. Every file recorded in eoria.json exists.
  • registry-dependencies. Every item that an installed item depends on is installed too.
  • npm-dependencies. package.json lists every package the installed items declare.

The last two need the registry. When it does not answer they turn into warnings.

--fix runs the same code as init for the three repairs that cannot clobber your work. It adds the path alias, writes a missing unistyles.ts, and installs missing packages. Then it checks again and reports the second pass. It leaves your root layout and your Babel config alone, and the remedy line tells you what to paste.

{
"ok": false,
"root": "/work/app",
"checks": [
{
"id": "alias",
"status": "fail",
"message": "tsconfig.json has no \"@/*\" path, so @/components/ui does not resolve.",
"remedy": "Run `eoria doctor --fix`, or add \"@/*\": [\"./src/*\"] to compilerOptions.paths.",
"fixable": true
}
],
"fixed": [],
"summary": { "pass": 10, "warn": 0, "fail": 1 }
}

The example shows one of the eleven checks. ok is false when any check failed. remedy is null on a pass. fixable marks the checks --fix can repair, and fixed lists the ids it did repair in this run. With --json, progress lines and installer output go to stderr, so stdout stays parseable.

mcp

Terminal window
npx @eoria/cli mcp

Starts a Model Context Protocol server on stdin and stdout, so an agent can call the CLI as tools without a shell. Your editor or agent starts it as a child process on your machine and stops it when the session ends. There is nothing to host and no account to make, and it reads the same static registry as every other command.

You rarely type that command yourself. The client runs it, and mcp init writes the entry the client reads:

Terminal window
npx @eoria/cli mcp init --client claude

--client takes claude, cursor, vscode or codex. Without it you get the four names. --force replaces an eoria entry that has other settings. --cwd, --registry and --docs-url go in the entry’s args and apply to every tool.

MCP server has the file each client reads, the seven tools and what they can change.

eoria.json

eoria.json
{
"registry": "https://eoria.adamtrip.pt/r",
"components": "src/components/ui",
"alias": "@/components/ui",
"installed": {
"text": { "src/components/ui/text.tsx": "3f0b…" },
"button": { "src/components/ui/button.tsx": "a91c…" }
}
}
Key What it holds
registry Registry URL, or a local folder
components Folder that receives component files
blocks Optional. Folder that receives block files. Defaults to blocks next to components
alias Import alias that resolves to components
installed Hash of every file add wrote, by item name and file path

Commit it. The installed hashes are what add and diff use to spot local edits, keyed by file path, so renaming components or blocks after installing loses that history. registry can be a URL or a local folder, which is how the repository tests the CLI against its own registry/dist.

shadcn compatibility

The registry is plain shadcn registry JSON. The index is at /r/index.json and each item at /r/<name>.json with its source inlined. If your team already runs the shadcn CLI, this works too:

Terminal window
npx shadcn@latest add https://eoria.adamtrip.pt/r/button.json

You lose the hash tracking, diff and extend, but the files are identical.

Hosting your own

The registry is a folder. pnpm build:registry writes registry/dist, and the docs build copies it into public/r. Deploy the docs output to any static host and point registry in eoria.json at it. There is no server.

registry/registry.json lists every item with its files, npm dependencies and registry dependencies. The build fails if an item names a registry dependency that does not exist, so a typo cannot ship.