About marko-ui
marko-ui is a CLI skill for using Marko 6 UI components from a registry. It guides component discovery, documentation lookup, installation, and change checks.
Key features
- 01Search the component registry
- 02Read component documentation
- 03Add and compare components
- 04Check project configuration
- 05Sync component lists for agents
Use cases
- 01Find a suitable Marko 6 component
- 02Review props before writing markup
- 03Install or update UI components
- 04Check local changes against registry
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ svallory/ marko-ui --skill marko-uiAfter installing, type/marko-uiin Claude Code to confirm it loaded.
Details
From the project READMEmarko-ui
marko-ui is shadcn/ui for Marko 6: accessible components whose behavior comes from Zag.js state machines and whose styling is Tailwind v4. Use the marko-ui CLI for everything below — do not hand-write a component that the registry already ships, and do not guess a component's props.
Run the CLI with the project's package runner (bunx marko-ui, npx marko-ui, pnpm dlx marko-ui). Every command is non-interactive when stdin is not a TTY: confirmations are taken as yes, init uses its defaults, and add with no component names exits 2 instead of opening a picker.
First: which distribution is this project on?
Read distribution in components.json (absent means copy). It changes how components are obtained and customized.
copy (default) | import | |
|---|---|---|
| Components live in | the project, under the ui alias (src/components/ui/<name>/) | node_modules/@marko-ui/shadcn |
| Get a component | marko-ui add <name> -y | nothing to add — import @marko-ui/shadcn/ui/<name>/<name>.marko |
| Customize | edit the component file (it is the project's code) | override mu-* hook classes from the project stylesheet |
No components.json means marko-ui is not set up: run marko-ui init (add --agents to also write the agent docs).
Workflow
marko-ui search -q <query>— find a component (--jsonfor machine output). It matches whole words against each item's name, title and description; each extra word narrows the ranking, and items matching none of your words are left out. Every item costs context, so search before guessing a name. The installed ones are listed in the project'sAGENTS.md.marko-ui docs <name>— the component's docs as markdown: install, usage, parts, props, events, keyboard/accessibility, then the essential examples. Read this before writing markup for a component you have not used in this session. It is deliberately small — at most three examples (the first plus the ones flagged essential), with the rest listed under "More examples".--examplesprints every example;--example <id…>prints only the ones you name.--listprints the index.docs <name> --jsonreturns the same markdown (and the structured model it was rendered from) in the envelope below. The markdown is written for you: demo sources come through with maintainer comments stripped, the prop list is one line per prop (name: type = default — description), a= fixed Xcell means the component sets that value and yours is ignored, and| undefinedis dropped since almost everything is optional (required props are marked); an absent default means not recorded, NOT "there is no default". Import paths in the snippets follow the project's owncomponents.json(distribution+aliases.ui), so what you read is what lands in the project. For a component the project has INSTALLED,docsdescribes the installed version without the network — the copy distribution reads the copyaddfetched and cached undernode_modules/.cache/marko-ui/(anaddthat could not fetch a docs file still installs, with aDOCS_CACHE_FAILEDwarning, anddocsreads the registry later), the import distribution reads the docs inside the installed@marko-ui/shadcn— and omits the install line; stderr then sayssource: installed (…)(data.components[].sourceunder--json).--remoteis the way out of the installed version: it reads the registry's current docs (what an upgrade would bring) even for an installed component. The registry's docs are one file per component that the item references, soshow <name>prints acomponentDocsRefURL, never the docs themselves; a registry item without one has no docs, anddocssays so (NOT_FOUND, suggestingshow). A component that is not installed comes from the registry, with its exactmarko-ui add <name> -y.marko-ui show <name>— the registry item as JSON;--fileslists what would be written,--depslists npm and registry dependencies.showis a JSON command in EVERY mode, so--jsonis accepted and changes nothing, and ashowfailure is themarko-ui/errorenvelope on stdout even in a terminal — the same document a pipe would get, not prose. Parse stdout either way.marko-ui add <name> -y --json— install (copy distribution). Always pass--jsonwhen you are driving this yourself: it returns which files werecreated/updated/skipped, which dependencies were installed, and any warnings, so you never have to parse the human output to find out what happened.--dry-runpreviews the same shape withdryRun: trueand PLANNED statuses (--dry-run --json);--overwritereplaces local files.marko-ui diff <name> --json— compare local edits against the registry version: every file with a status (unchanged/modified/missing) and, for a modified one, the unified diff as plain text with no ANSI.okistruewhether or not anything differs —diffreports, it does not judge.marko-ui doctor --json— health checks; exit code 3 means something is broken and each failed check carries afixfield with the command (or one-line action) that fixes it. Run it afterinit/addand before reporting the work done.marko-ui agents sync --json— refresh the component list inAGENTS.mdafter adding or removing components.agentsChangedsays whether the file actually changed;skillssays which agent skills were installed vs already present.
marko-ui manifest prints every command, flag, exit code, and error code as JSON when you need the exact surface; marko-ui manifest <command> prints just that one command (name or alias) plus the exit codes — cheaper when you are checking a single flag. An unknown name is a usage error (exit 2).
Reading JSON
Every --json payload is one document: { "$type", "version", "ok", "data" }. Branch on $type before reading data — $type is marko-ui/search, marko-ui/status, marko-ui/show, marko-ui/docs, marko-ui/docs.list, marko-ui/doctor, marko-ui/registry.list or marko-ui/manifest. It is printed MINIFIED on one line (a pipe or a program) and pretty-printed only when stdout is a terminal, because the reader of this output is usually a program and indentation is tokens you pay for. There is no --pretty flag.
search items carry name, short type ("ui", "block"), description and registry — nothing derivable. To install a result from a registry other than the default, pass <registry>/<name>; addArgument in data states that rule once, and only when a non-default registry is in the results.
status reports config.distribution, config.visualStyle and config.iconLibrary; config.resolvedPaths gives cwd (absolute, once) and every other path RELATIVE to it.
A failure is the marko-ui/error envelope described under Reading errors — never prose. The one command where that holds in EVERY mode, terminal included, is show: it is a JSON command in every mode, so its failures are documents on stdout too.
Reading what a command changed
add, init, eject, diff and agents sync all return the same three
shapes, so one parser covers all of them:
files:{ path, status }, relative tocwd(given once at the top).warnings:{ code, message, fix }. A warning is NOT a failure — the command did its job,okstaystrue, and the warning says what is left. Codes are listed inmarko-ui manifestunderwarningCodes. The human path prints the SAMEmessageandfix, so what you read and what you parse cannot disagree.next: commands worth running next. Act on it rather than re-deriving it.
status values are a closed vocabulary, not prose:
| status | means |
|---|---|
created | this run wrote a file that did not exist |
updated | this run replaced an existing file |
unchanged | the file already matched the registry byte for byte |
skipped | the file exists, differs, and was left alone (--overwrite would have replaced it) |
removed | a stale icon map this run deleted |
missing | (diff only) the registry has it, the project does not |
skipped and unchanged are different facts. Re-running add on an
unchanged component reports unchanged, not skipped; skipped means the
file had drifted and nothing replaced it. Read the status before telling a user
something was installed — created is the only status that means "this run
wrote it".
For add, also check items.resolved (what was ACTUALLY installed, which
includes registry dependencies pulled in beyond what you asked for — add button can resolve utils too), dependencies (installed / present /
failed), and dryRun: true on a preview, where the statuses are PLANNED,
not observed.
Reading errors
stdout carries the result; stderr carries every problem. If you run the CLI with a pipe or capture stdout, you get the command's payload and nothing else — no log line, no warning, no failure text. Never parse stderr as data.
With --json, a failure is one JSON object on stdout and nothing else, in this shape:
{
"$type": "marko-ui/error",
"version": 1,
"ok": false,
"error": {
"code": "NOT_FOUND",
"message": "Registry item \"buton\" was not found. Did you mean \"button\"?",
"suggestion": "Did you mean \"button\"?",
"details": { "itemName": "buton", "suggestions": ["button"] }
}
}
Branch on error.code, never on the message text. Every code is listed in marko-ui manifest under errorCodes — the common ones are NOT_FOUND (unknown item), NOT_CONFIGURED (no components.json, or a registry that isn't configured), INVALID_CONFIG, NETWORK_ERROR (registry/docs host unreachable — exit 4), FETCH_ERROR (the host answered 5xx or 429 — exit 4, retry later), MISSING_ENV_VARS, MONOREPO_ROOT (run from a workspace, not the repo root), USAGE_ERROR (bad invocation, including an unknown command or flag), CHECK_FAILED (a doctor/validate/agents --check found problems), and PROJECT_NOT_FOUND (no Marko project at that directory).
A connection failure (refused port, DNS failure, timeout) is NETWORK_ERROR with exit 4 on every registry-backed command. A server that answered 5xx or 429 is FETCH_ERROR, also exit 4: the registry is failing and the same command may succeed later. Every other HTTP status exits 1 with its own code: UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), GONE (410), REQUEST_REJECTED for an unexpected 4xx such as 400 — retrying will not change them.
details.suggestions carries "did you mean" candidates when the registry index was reachable, so you can retry without parsing prose. An unknown item name in docs, show, add or diff is the common case.
details never carries a stack trace, an absolute path under your home directory, or a credential-bearing URL — paths are shortened and registry URLs have their query string and credentials stripped. A stack is on stderr only under MARKO_UI_DEBUG.
One name can fail while the others succeed: marko-ui docs nope button prints button's markdown on stdout, reports nope on stderr, and exits 1.
Exit codes: 0 ok · 1 operational failure · 2 usage error (the code USAGE_ERROR, always and only — a wrong or missing argument or flag value; a missing project, an uninitialized project or the wrong distribution are their own codes, exit 1) · 3 a check found problems · 4 the registry could not be reached or is failing (connection failure, 5xx, 429). marko-ui manifest lists both tables (exitCodes, errorCodes).
Using components
import Dialog from "../components/ui/dialog/dialog.marko";
import Button from "../components/ui/button/button.marko";
<let/open=false/>
<Dialog open:=open>
<@trigger|props|>
<Button ...props>Edit profile</Button>
</@trigger>
<@title>Edit profile</@title>
<@description>Make changes to your profile here.</@description>
<p>Form fields go here.</p>
</Dialog>
- Parts are attribute tags (
<@trigger>,<@title>), not separately imported subcomponents.marko-ui docs <name>lists each component's parts. <@trigger|props|>hands you the machine props to spread onto whatever element you render. It is the equivalent of Radix'sasChild.- State binds with
:=(open:=open,value:=value,checked:=checked), or pass the value plus itsxChangehandler (open=open openChange=(v) => { open = v }). - On the copy distribution
addalso generates a taglib (marko.json), unless the project already has its ownmarko.json, so installed components are usable as tags without an import (<Button>,<CardHeader>). An explicit import always works too. - Never import a component as
Input. It shadows Marko's reserved props type. The input component is imported asTextInput.
Pitfalls that break components silently
Each of these has caused a real bug. Check your code against the list before finishing.
- Controlled props pin the machine.
open=/value=/checked=WITHOUT the matchingopenChange/valueChange/checkedChange(or the:=bind) freezes the component in that state. For an initial value only, usedefaultOpen=/defaultValue=/defaultChecked=. - Boolean
aria-*/data-*attributes serialize wrong. Marko renders booleantrueas a bare attribute:aria-selected=(bool)emitsaria-selected=""anddata-active=(bool)never matches adata-[active=true]:Tailwind variant. Wrap inString(...). - Nothing from Zag crosses a tag boundary or lives in state. Machine services and
apiobjects contain functions and cannot be serialized for resume. Pass plain data, or a closure written in the template (parentApi=() => api()), and callapi()at the use site. - Do not read a parent component's
api()inside a child's attribute-tag body. Route it through the child's input as a closure instead. The symptom isTypeError: ... is not a functionafter an overlay opens. - Overrides that do nothing (import distribution). The style layer must be imported with
layer(components)and the matching@custom-variant style-<name>line must exist in the project's stylesheet. Both fail silently. - Unstyled components (copy distribution). The theme stylesheet is not loading. Check that the file recorded as
tailwind.cssincomponents.jsonis imported by the root layout and that@tailwindcss/viteis in the Vite config.marko-ui initwires both; a hand-rolled setup often misses one. "Cannot find module @zag-js/..."ormarko-zag. A component's npm dependencies are missing.marko-ui addinstalls them;marko-ui show <name> --depslists them.
For Marko 6 language rules (<let>, <const>, <for>, event handlers, TypeScript in templates) use the marko6 skill; for @marko/run routing use marko-run.
Editing a component (copy distribution)
The file is project code. Each .marko file starts with a static const styles = { ... } block holding the Tailwind classes per part: change classes there. Interactive components wire their machine with one tag, <zag/api=() => machine from=input/>, which picks the machine's props out of input and returns the api getter; keyboard handling, focus management, and ARIA come from the machine, so restyling cannot break accessibility. After editing, marko-ui diff <name> shows what diverged from the registry.
More
- Docs: https://marko-ui.saulo.tech/docs (component pages as markdown:
/docs/components/<name>.md) - Writing a new component: https://marko-ui.saulo.tech/docs/creating-components
- Agent setup and troubleshooting: https://marko-ui.saulo.tech/docs/working-with-ai