@zoahhq/cli
v0.1.9
Published
Zoah CLI: import React components, publish via Zoah, swap local imports
Readme
@zoahhq/cli
The Zoah CLI. Sign in from your terminal, import first-party React components into Zoah, and optionally rewrite local imports.
Published to npm as @zoahhq/cli. The package installs the zoah command.
Install
npm install -g @zoahhq/cliVerify the installation with zoah --version. The package also provides opacity as a temporary compatibility alias.
Quick start
zoah login # sign in via your browser
zoah import # scan ./src, import and publish your components
zoah import --swap # also rewrite local imports to the published packageCommands
zoah login
Opens your browser to the Zoah sign-in flow. It saves the credential with chmod 600. Other commands reuse its token and base URL.
Credentials are layered (same precedence as git config local > global):
- Default, global: writes
~/.zoah/credentials.json. One sign-in covers every Zoah project on this machine. --local: writes<cwd>/.zoah/credentials.json. Use this for a project-specific account or instance. The CLI also writes<cwd>/.zoah/.gitignore.
Reads always check local first; falls back to global.
zoah login # global default
zoah login --local # project-specific override
zoah login --base-url https://zoah.example.com
zoah login --organization acme # skip the organization listAfter you sign in, the CLI asks which organization to work in. Your active organization in the web app is first and selected. Use the arrow keys to choose another, then press Enter. An account with one organization skips the question.
The CLI keeps this choice. Changing organizations in the web app does not change it. New projects from zoah import go into this organization, and so do directories that are imported but not swapped yet. A swapped directory stays on its project (see Where a directory stands).
The CLI uses ZOAH_BASE_URL when it is set. Otherwise, it uses https://zoah.com. OPACITY_BASE_URL remains a compatibility fallback.
zoah logout
By default, this removes the active credentials. It removes project-local credentials when they exist, and otherwise removes global credentials.
zoah logout: clear the active layer only.zoah logout --global: remove~/.zoah/credentials.jsoneven when a project-local override is also present.
To clear both, run logout twice.
zoah status
Prints who is signed in, the CLI's organization, and this directory's project, package and state, plus what the next zoah import will do. It reads local files only.
zoah statuszoah whoami
Prints the active user, the source credentials file, the base URL, and the organization the CLI works in. Use this when project credentials hide the global token.
zoah whoami
# Signed in as [email protected] (global).
# Source: ~/.zoah/credentials.json
# Base URL: https://zoah.com
# Organization: Acme (acme)zoah organization
zoah organization list # your organizations; * marks the CLI's
zoah organization switch # choose from the list with the arrow keys
zoah organization switch acme # or name one by slug, name or id
zoah organization create "Acme" # create one; switch to it to work thereswitch changes the organization for the credential it came from, so a --local login keeps its own organization. The MCP server uses the same organization unless connect names another.
zoah mcp
Runs the Zoah MCP server over stdio so a coding agent (Claude Code, Codex, Cursor, or any MCP-capable harness) can read and edit a Zoah project from your terminal session. The agent gets the same verbs the designer uses: nodes, pages, components, slots, variants, variables, themes, breakpoints and events, plus branch and pull-request tools.
Set it up
1. Sign in once. The MCP server reuses the credential zoah login
writes. It never asks the agent for one.
npm i -g @zoahhq/cli
zoah login2. Add the server to your harness. The guided installer is the fast path:
zoah mcp initIt asks four things:
| Question | Options | Pick this when |
| ------------------ | ----------------------------------------------------------- | ------------------------------------------------------------- |
| Which harness | Claude Code, Cursor, VS Code, Codex, or print the snippet | Pick the harness that will use Zoah |
| How to launch | npx -y @zoahhq/cli@latest, or the global zoah binary | npx for a config you check in, so teammates need no install |
| What agents may do | full access, or --read-only | read-only serves the query verbs alone |
| Default project | the repo's linked project from .zoah/config.json, or none | baking it in means connect needs no IDs |
It merges into an existing project config without disturbing other servers.
Every answer is also a flag (--harness, --global-command, --read-only,
--project, --print) for scripted setup.
For Codex, the installer runs codex mcp add. Codex writes the global server
entry that its CLI and IDE extension share. The installer checks for an
existing zoah server first. Interactive setup asks before replacement.
Scripted setup leaves an existing entry unchanged. If Codex is unavailable or
its server list is unsafe to read, the installer prints a complete global TOML
entry instead. Zoah never edits Codex TOML directly. See the
Codex MCP documentation.
The confirmation shows the resolved instance as one URL. The standard
production instance is https://zoah.com.
To write the entry by hand instead:
{
"mcpServers": {
"zoah": {
"command": "npx",
"args": ["-y", "@zoahhq/cli@latest", "mcp"]
}
}
}3. Point the agent at a project. In the agent session:
connect(project: "my-project")connect signs in when the credential is missing or expired. It returns a URL
for you to approve in a browser, then resolves the project by slug, name or id
and opens the session. Give it branch to pick one. With no branch it creates a
fresh agent/<timestamp> branch off main, so an agent's edits stay reviewable
rather than landing on main.
4. Check it worked. status reports the connected project, the branch, and
organization role. It also reports credential health. whoami reports the
identity, role, and instance.
What the agent gets
The server exposes each Action API verb that can run in a headless project session without direct human confirmation. Confirmation actions stay in the Zoah app. The server replaces renderer-only and workspace-agent verbs with the session tools below.
| Group | Tools |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Session | connect, status, pullEvents, disconnect, whoami, logout, describeProperties |
| Organizations and projects | listOrganizations, createOrganization, listProjects, createProject, updateProject |
| Branches | listBranches, createBranch |
| Pull requests | createPullRequest, getPullRequest, updatePullRequest, commentOnPullRequest |
| Canvas | one per applicable Action API verb for nodes, pages, components, slots, variants, variables, themes, breakpoints, events, and queries |
Two properties are worth knowing. Mutation tools return only after the sync
gateway acks the write, so a returned success means persisted, not queued. And
the server does not provide branch, project, or organization deletion.
pullEvents returns structured rejection and collaborator action details. It
also returns merge summaries. Sync and cache signals mean the agent should
re-query after reconnect catch-up.
For a read role, the server hides mutation tools and connects to an existing
branch. It does not create an agent branch.
Deleting one is irreversible from here, and it is easy for an agent to reach for
while recovering from its own mistake. That stays in the web UI, where a person
can see what they are about to destroy.
Working against local dev
The published bundle targets https://zoah.com. For a local instance, link
the checkout instead of installing from npm, then sign in against it:
zoah login --base-url http://localhost:5070connect accepts a baseUrl parameter, and it wins over the saved
credential. It exists for the clean-workspace case: with no credential file
yet, the built-in default is a guess, and on a multi-instance dev machine it
is usually the wrong port. The choice is saved, so later calls agree - a
session cannot silently straddle two instances, and status reports a
mismatch between the open session and the credential on disk
(baseUrlMismatch) rather than letting one develop unnoticed. Build and link
steps are in DEVELOPMENT.md.
Design and details: docs/action-api-mcp.md.
zoah import [path]
Scans first-party React components and sends an import payload to Zoah. It publishes a package and writes project state under .zoah/. The default path is src.
zoah import # scans ./src
zoah import src/components # scope to a subdirectory
zoah import --swap # also rewrite local imports after publish
zoah import --project-name my-ui # override the first project name
zoah import --new-project # start over in a new project (before a swap)
zoah import --branch main # target a specific branch
zoah import --debug # also write .zoah/debug/payload.json
zoah import --print-issues warn # print issue details at or above this level
zoah import --dry-run # preview locally without an API callThe first import creates a project and saves its IDs to .zoah/config.json. Before it scans, zoah import says where the directory stands and where this import goes.
Where a directory stands
zoah status prints this without scanning or calling the API.
| State | What it means | What zoah import does |
| -------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New | No .zoah/config.json. | Creates a project in the CLI's organization. |
| Imported | Linked to a project, but no source file uses its package yet. | Updates the linked project. If the CLI's organization is different, it creates a project there and relinks the directory. --new-project also starts a new project. |
| Swapped | zoah swap rewrote component files to use the package. | Adds and updates components in the linked project, whichever organization the CLI is in. Swapped components are already in Zoah, so the scan skips them. --new-project is refused, because the code depends on the package. |
A swapped directory stays on its project because its code imports that project's package. It still takes new components: add one and run zoah import --swap.
Notable flags:
--swap: runszoah swapafter a successful import.--project-name <name>: sets the name of a new project. Later imports use the project ID in.zoah/config.json.--new-project: creates a new project in the CLI's organization and relinks the directory. Not possible once the directory is swapped.--branch <name>: defaults to the project's saved branch (usuallymain).--base-url <url>: overrides the URL saved at login.--print-issues <level>: also prints full details for every issue at or aboveinfo | warn | error.--dry-run: runs the local pipeline without publishing. It still writes.zoah/debug/issues.json.
The CLI skips components imported directly by an entry file (src/main.tsx, src/index.tsx, etc.). Translating them would blank the running app.
zoah preflight [path]
Runs the import pipeline locally without publishing. It writes .zoah/debug/issues.json but does not call the API or change project state.
- preview what a real
zoah importwould do on a checkout you have not published, - audit which codes a codebase trips before deciding whether to upgrade the CLI,
- regenerate the issues snapshot after a source change without re-publishing.
zoah preflight # scans ./src
zoah preflight src/components # scope to a subdirectory
zoah preflight --print-issues warn # also print issue detailsThe full code list, grouped by what the swap actually does (cleanly / visibly degrades / refuses), is documented in docs/supported-shapes.md in the repo.
zoah swap [path]
Installs the published Zoah package and wraps your local components. It reads .zoah/components.json and .zoah/manifest.json from zoah import.
zoah swap # install the package and rewrite imports
zoah swap --dry-run # print diffs only
zoah swap --print-issues warn # print issue detailsThe dry-run mode is the safe way to preview what swap will do before letting it touch your source tree.
React wrappers preserve root HTML props and use elementProps for supported nested form controls.
See native HTML elements for the supported tags, examples, and limits.
Authenticated installs (.npmrc management)
Published packages carry the project's privacy. CLI imports are private by
default, so the install needs credentials. When the repo's .npmrc (and your
~/.npmrc) has no token for the Zoah registry host, swap mints a registry
API key for the project's organization and manages the file for you:
A key reads only its own organization's packages. When
.npmrcalready has a key, swap asks the registry whether that key can read this package. If the registry refuses it (401 or 403), for example because the key came from a project in another organization, swap replaces it with a key for this project and says so..npmrcholds one key per registry host, so a repo that installs packages from two organizations needs a key that can read both.The key is requested through the authenticated import API, requires write access in the project's organization, and is reused on repeat runs (one key per project + user, not one per invocation). It appears in Project Settings → Package as
CLI import · <project>and is revocable there.The token line (
//<host>/:_authToken=…) is written to<repo>/.npmrcwith owner-only permissions (0600), and.npmrcis added to.gitignore.If
.npmrcis already tracked by git,.gitignorecannot protect it. swap prints a loud warning telling you togit rm --cached .npmrcbefore your next commit.When signed out, the install continues without authentication. Public packages still work. A private package install returns the registry's 401.
Reading the import output
After each zoah import run, the CLI prints a status table for each component:
Component Import Swap
──────────────────── ────── ────
Avatar █ █
Button █ █ 1 error
Card █ ░ █ 2 warnings, 1 info
ToastProvider █ █
File-level:
src/utils.ts █ 1 warning
src/App.tsx ░ 1 info
1 error, 2 warnings, 1 info. See .zoah/debug/issues.json
View in Zoah: https://zoah.com/projects/<id>/designEach cell is two glyphs: the severity slot on the left summarises the worst outcome for that (row, phase) pair, and the info-note slot on the right lights up when any info-level notes accrued.
| Cell | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| █ green | Phase ran cleanly (component imported successfully, no issues). |
| █ yellow | Phase ran with at least one warning. |
| █ red | Phase ran with at least one error. |
| ░ dim | On a component row, phase didn't reach this row (e.g. swap blocked by an import error). On a File-level row, phase ran with only info-level notes (e.g. an entry file that was intentionally skipped). |
| blank | On a File-level row, the phase did not touch this file. Sparse rows leave unused phases empty. |
| trailing ░ | The cell has at least one info-level note beside a colored severity block. See the JSON for details. |
The trailing summary line counts errors, warnings, and info notes (zero-counts are omitted, so a clean run with only info notes still shows e.g. 3 info). Full per-issue detail is in the JSON snapshot.
.zoah/debug/issues.json
Every run writes all issues to .zoah/debug/issues.json, grouped by phase and severity. Shape:
{
"schemaVersion": 1,
"generatedAt": "2026-05-04T18:00:00.000Z",
"commitSha": "abc1234+dirty",
"summary": { "total": 12 },
"phases": {
"import": { "total": 11, "issues": { "error": [...], "warn": [...], "info": [...] } },
"package": { "total": 0, "issues": { "error": [], "warn": [], "info": [] } },
"swap": { "total": 1, "issues": { "error": [], "warn": [...], "info": [] } }
}
}Each issue carries a stable code (e.g. DYNAMIC_CLASSNAME_UNRESOLVED, CODEMOD_FILE_REFUSED), a target describing what it applies to (node / component / file / project), an optional location with a 3-line source snippet, and a commitSha stamp. The full code list with severity and phase mappings, grouped by outcome, is documented in docs/supported-shapes.md in the repo.
When the import fails (e.g. the server returns 400 because no components were extractable), the CLI still writes this file and prints the table so you can see exactly which components couldn't be imported and why. Pass --print-issues warn (or info/error) to also print snippet + caret detail inline:
warn Card · import · DYNAMIC_CLASSNAME_UNRESOLVED src/Card.tsx:42:18
Dynamic className expression couldn't be resolved to styles
41 | const cls = `card-${variant}`;
42 | return <div className={cls}>...</div>;
^^^
43 | }What the CLI writes
Global (per-machine, never inside a project):
~/.zoah/credentials.json: auth token and base URL (chmod 600). Written byzoah loginunless a project overrides it.
Project-local (paths relative to the project where you run zoah). The CLI manages .zoah/.gitignore so secrets and debug output stay out of git.
Commit these files. They bind the checkout to the Zoah project:
.zoah/config.json: organization and project binding, written on the first import..zoah/components.json: component IDs, package exports, and slot anchors..zoah/manifest.json: published package name, version, and registry URL.
Ignored automatically through .zoah/.gitignore:
.zoah/credentials.json: local credential fromlogin --local..zoah/debug/issues.json: issues from each run, grouped by phase and severity..zoah/debug/payload.json: raw import payload from--debug.
The CLI reads old .opacity state when the matching .zoah file does not exist. New writes use .zoah.
Environment variables
ZOAH_BASE_URL: default base URL when no flag or credential sets one.ZOAH_BRANCH: default branch forimportwhen--branchis not passed.
The old OPACITY_BASE_URL and OPACITY_BRANCH variables remain compatibility fallbacks.
