CLI
The muhkoo CLI drives the platform from your terminal — create apps,
provision their backend (tables, agents, functions), deploy hosted clients, and
manage domains, keys, and releases. It’s built on @muhkoo/connect, so it speaks
the same APIs your app does.
Install
Section titled “Install”npm install -g @muhkoo/cli npx @muhkoo/cli —help Requires Node.js 20+. Verify with muhkoo --version.
Sign in
Section titled “Sign in”Two ways to authenticate; both store the resulting session token in
~/.muhkoo/config.json.
muhkoo login --webOpens auth.muhkoo.dev in your browser, where you sign in however you like —
password, passkey, or Google. The CLI captures the session over a one-time
localhost redirect, so your credentials never touch the terminal. Best when you
use passkeys or Google sign-in.
muhkoo login# Username: alice# Password: ••••••••# ✓ Signed in as alice → https://api.muhkoo.devRuns the zero-knowledge login locally — your password never leaves the machine.
Every command can also override the stored session with --token <sessionToken>
or the MUHKOO_DEV_TOKEN environment variable — handy in CI, where you paste a
token from the portal instead of logging in.
muhkoo login --token <sessionToken> saves one as your stored session.
Terminal sign-in reads --username / --password, then MUHKOO_USERNAME /
MUHKOO_PASSWORD, then prompts. Check who you are, or clear the session:
muhkoo whoami # base, email and plan (--json for machine-readable)muhkoo logout # clears the stored token; keeps your default --baseCreate & provision an app
Section titled “Create & provision an app”A provision spec is a JSON file describing your backend. The CLI applies it idempotently — re-running updates tables, agents, and functions in place.
{ "slug": "team-standup", "allowedOrigins": "*", "email": "you@example.com", // only needed to bootstrap a new developer "crossOriginIsolation": true, // optional — serve the app cross-origin-isolated (SharedArrayBuffer / threaded wasm). See Hosting. "tables": [ { "table": "tasks", "columns": [ { "name": "title", "type": "text", "nullable": false }, { "name": "done", "type": "boolean", "nullable": false, "default": false } ] } ], "agents": [ { "handle": "@helper", "model": "…", "systemPrompt": "…", "enableChannel": "general" } ], "functions": [ { "name": "hello", "code": "export default { async fetch() { return new Response('hi'); } }" } ]}muhkoo provision --spec app.jsonThis creates the app (issuing test + live key pairs), provisions the tables, and
creates any agents and functions. It writes .muhkoo-app.json — the app id
and keys — so later commands run with no extra flags.
muhkoo provision --spec app.json --dry-run # preview the API calls, change nothingmuhkoo provision --spec app.json --enable # enable agents/functions on their channelsmuhkoo provision --spec app.json --out ci/app-record.json # write the record somewhere else--enable reads the agent and function ids from the existing .muhkoo-app.json
(or --out file), so run the plain provision first. It resolves each
enableChannel name to a Space, so the app must have been run once so its
channels exist. The record carries your secret keys, so it is written owner-only
(mode 0600). crossOriginIsolation may sit at the top level of the spec or
under hosting, and is re-applied each time you provision an existing app.
Keep secrets out of the committed spec
Section titled “Keep secrets out of the committed spec”A function’s code runs server-side and may need a secret (an API token, a
signing key). Don’t commit it. Keep a committed app.template.json with
placeholders, and generate the real app.json at provision time by substituting
values from your environment — so the secret lives in CI/your shell, never in git:
{ "slug": "team-standup", "functions": [ { "name": "notify", "code": "export default { async fetch() { /* uses __SLACK_TOKEN__ */ } }" } ]}# Substitute, provision, then discard the generated spec.sed "s/__SLACK_TOKEN__/$SLACK_TOKEN/" app.template.json > app.jsonmuhkoo provision --spec app.jsonBecause provision is idempotent, re-running it after editing the template
updates tables, agents, and functions in place — it’s safe to run on every change.
Deploy a client
Section titled “Deploy a client”Build your client, then ship its dist/ to Muhkoo hosting. Uploads are
content-addressed — only changed files transfer — and a release is an atomic
pointer flip (instant, and rollback-able).
muhkoo deploy# → Deploying 18 files from dist/ → https://api.muhkoo.dev# 12 uploaded, 6 unchanged (18 unique files)# ✓ Deployed. live at: https://team-standup.apps.muhkoo.devdeploy uploads dist/ by default (--dist <dir> picks another folder), and
reads the app id and a secret key from .muhkoo-app.json. With no secret key at
all it falls back to your developer session. In CI, pass them explicitly:
muhkoo deploy --app "$MUHKOO_APP_ID" --key "$MUHKOO_DEPLOY_KEY"Which environment a deploy targets
Section titled “Which environment a deploy targets”Every app has a test and a production environment, and the deploy lands in
whichever the secret key belongs to. muhkoo deploy uses the test key
from .muhkoo-app.json by default; pass a live secret key to ship production:
muhkoo deploy # → test (<slug>.test.apps.muhkoo.dev)muhkoo deploy --key "$MUHKOO_LIVE_SK" # → production (<slug>.apps.muhkoo.dev)Iterate against test, then promote test → production — in place, as an atomic
pointer flip — with muhkoo promote (or from the portal):
muhkoo promote # copy the test release + functions onto prodpromote is owner-only (it uses your developer session, not an app key). It takes
the app id as muhkoo promote <appId>, or reads it from $MUHKOO_APP_ID or
.muhkoo-app.json. If part of the promote fails (a function, say), it prints each
error and exits with status 1 so CI notices. Production data and per-env app config (CORS +
redirect URIs) are never touched. See Environments & deploy.
Continuous deployment
Section titled “Continuous deployment”deploy is built for CI: it authenticates with an app secret key (no
interactive login) and only transfers changed files. Build your client, then run
muhkoo deploy with the app id and a deploy key from your CI secrets — a live
key to ship production, a test key for a preview environment:
name: Deployon: push: branches: [main]jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm ci && npm run build - run: npx @muhkoo/cli deploy env: MUHKOO_APP_ID: ${{ secrets.MUHKOO_APP_ID }} MUHKOO_DEPLOY_KEY: ${{ secrets.MUHKOO_LIVE_SK }} # an app secret keyTo provision the backend in CI instead (or as well), authenticate with a
session token from the portal via MUHKOO_DEV_TOKEN,
and run muhkoo provision --spec app.json.
Environments
Section titled “Environments”--base selects the API base for any command — prod (default), staging,
local, or a literal URL. muhkoo login remembers your choice. The order is
--base, then MUHKOO_API_BASE, then the base saved by login, then prod
(deploy and promote consult the baseUrl in .muhkoo-app.json before the
saved base).
muhkoo apps ls --base stagingmuhkoo deploy --base local # http://localhost:8787muhkoo whoami --base https://api.example.comManaging apps
Section titled “Managing apps”muhkoo apps ls # your appsmuhkoo apps get <appId> # details + key metadata + hosting URLmuhkoo apps create --slug my-app --email you@x # create (bootstraps billing on first app) # --origins <list> sets allowed origins (default *)muhkoo apps slug my-app # is a slug available?muhkoo apps rm <appId> # delete an app and revoke its keysmuhkoo keys rotate <appId> # rotate all four keys (revokes the old set)
muhkoo deploy # publish dist/ to the test envmuhkoo promote # promote test → production, in placemuhkoo hosting status <appId> # releases + current pointermuhkoo hosting rollback <appId> --release <id> # instant rollbackmuhkoo hosting rm-release <appId> <releaseId> # delete one stored releasemuhkoo hosting unpublish <appId> # remove the live pointer
muhkoo domains add <appId> app.example.com # attach a custom domain (returns DNS records)muhkoo domains ls <appId>muhkoo domains rm <appId> app.example.com # needs a paid plan to add; removing is free
muhkoo tables ls <appId> · tables get <appId> <table> · tables rm <appId> <table>muhkoo agents ls|get|rm <appId> [<agentId>] · agents modelsmuhkoo agents enable|disable <appId> <agentId> --channel general # or --space <spaceId>muhkoo functions ls|get|rm <appId> [<functionId>] · functions code <appId> <functionId>muhkoo functions deploy <appId> --name hello --file hello.js [--http]muhkoo functions enable|disable <appId> <functionId> --space '#general' # or a spaceIdmuhkoo logs <appId> [--env live|test] [--limit 50] # inspect the server-log Space--channel <name> (or --space '#name') resolves a channel through the
publishable key in .muhkoo-app.json, so run it from a provisioned app
directory, or pass a raw --space <spaceId>. logs shows the log Space and the
count of recent entries, which are end-to-end encrypted to you; read them
decrypted in the portal’s Tools panel. Its --env defaults to live.
Add --json to most read commands for machine-readable output. Run
muhkoo <command> --help for the full option list on any command.
Preview an agent
Section titled “Preview an agent”muhkoo eject shows the system prompt and tools config a @Muhkoo*-decorated
agent description compiles to — what the agent will actually be told and allowed
to do — before you provision it.
muhkoo eject src/agent/agentApp.ts [ExportName]It runs the file through tsx (fetched with npx) against the
@muhkoo/connect installed in that project, which needs a tsconfig with
experimentalDecorators on.
Configuration & environment variables
Section titled “Configuration & environment variables”Most flags have an environment-variable equivalent, so CI never needs interactive input:
| Variable | Equivalent flag | Purpose |
|---|---|---|
MUHKOO_DEV_TOKEN | --token | Developer session token — for provision and account/app management in CI |
MUHKOO_DEPLOY_KEY | --key | App secret key (mk_*_sk_*) — authorizes deploy |
MUHKOO_APP_ID | --app | The app id, when not running from a provisioned app dir |
MUHKOO_API_BASE | --base | API base: prod (default), staging, local, or a literal URL |
MUHKOO_USERNAME, MUHKOO_PASSWORD | --username, --password | Credentials for terminal login |
A provisioned app directory carries its own .muhkoo-app.json (app id + keys), so
locally these are usually unnecessary — they exist mainly for CI and scripting.
Colour follows the usual conventions. NO_COLOR turns it off. From 0.14.0-alpha.9, FORCE_COLOR turns it on even when output is not
a terminal, except FORCE_COLOR=0 or FORCE_COLOR=false, which turn it off;
0.14.0-alpha.8 ignores FORCE_COLOR and colours only a terminal.
Machine credentials
Section titled “Machine credentials”A server, CI job or script authenticates with an access token rather than a user sign-in — scoped to what it needs, and expiring.
muhkoo tokens create <appId> --scopes db:read,db:write --expires-in 30muhkoo tokens create <appId> --env live --label ci-reader # --env test|live, --label names itmuhkoo tokens ls <appId>muhkoo tokens revoke <appId> <keyId>--scopes defaults to db:read,db:write. The valid scopes are db:read,
db:write, kv:read, kv:write, storage:read, storage:write,
messages:read, messages:write, functions:invoke and ai:infer.
The plaintext is shown once, at creation. See access tokens for the scope vocabulary.
Your filesystem
Section titled “Your filesystem”muhkoo vfs is client.vfs from the terminal — the same
code the SDK runs, not a reimplementation over HTTP.
muhkoo vfs ls / # first run pairs this machinemuhkoo vfs cd /apps/my-appmuhkoo vfs cat src/App.tsxmuhkoo vfs find '**/*.ts'muhkoo vfs history src/App.tsxmuhkoo vfs restore src/App.tsx 0cd is remembered between commands, so relative paths work the way they do in
a shell.
File names, commit messages and branch names come from whoever wrote them, and
any app you sign into can write them.
So from 0.14.0-alpha.9, the CLI prints control characters,
bidi marks and invisible characters in its output as a visible \uXXXX, where a
terminal would otherwise obey them: printed raw, a planted commit message could
set your clipboard. --json output stays valid JSON. vfs cat and
functions code keep a file’s own line breaks and tabs on a terminal, and are
written unescaped when piped. Both print text, so neither is a byte-exact copy:
for the exact bytes of a file, use muhkoo vfs get <path> [local]. vcs log and
vcs commit show a message’s first line, as git log --oneline does; the whole
message is in vcs log --json.
Pairing this machine
Section titled “Pairing this machine”The first command that needs your files asks for your password once, then pairs this machine: a random key is generated here, your master seed is wrapped under it, and only the ciphertext goes to your vault. The seed itself never lands on disk.
muhkoo devices ls # machines paired to your accountmuhkoo devices rm <id> # withdraw one machine's access, from anywhereRevoking works because the key here is useless without the ciphertext there — which is exactly what a stored seed could never offer.
Reading is free; writing needs an app
Section titled “Reading is free; writing needs an app”Storing bytes is metered, and the shard store attributes them to an app. So reads work with nothing signed in, and writes need an app:
muhkoo vfs use-app my-app.apps.muhkoo.devThe domain is enough when the app publishes /.well-known/muhkoo.json. Key
plaintexts are never stored server-side — they are issued once at
apps create — so for an app that publishes nothing, pass --key the first
time and the slug thereafter.
Your key/value store
Section titled “Your key/value store”muhkoo kv ls [prefix] lists the keys in your personal key/value store: the
store under client.kv, which your filesystem and version
control share. It prints names only and never reads a value, so listing
needs no pairing and no password.
muhkoo kv ls todos/ # one client.kv collectionmuhkoo kv ls vfs/ --json # your filesystem's records, as a JSON arrayWhen part of your filesystem cannot be read
Section titled “When part of your filesystem cannot be read”From 0.14.0-alpha.9, the SDK underneath the CLI treats everything it reads from your filesystem as untrusted. When part of a tree is unreadable or absurdly large, the command stops with an error rather than show you part of it, because a sync reads a missing path as a deleted one. Each error says what to do:
VFS: stopped walking /apps/my-app: it holds more than 500000 entries (reached at /apps/my-app/junk). If you do not recognise what is there, remove it with a recursive delete.VFS: /apps/my-app/junk (record 7Qk2mZr0bXw1LpA9vN3cTe) cannot be read: it does not decrypt with the key it should. It has been left as stored; remove it with a recursive delete.muhkoo vfs rm -r /apps/my-app/junkA walk stops past 500,000 entries, 32,000,000 characters of path, or 64
directories below where it started, which no real project comes near. A
recursive delete of an unreadable directory detaches it without reading it, and
leaves what it held for muhkoo vfs sweep. The root is the exception: it
cannot be deleted, so an unreadable root has no fix from the CLI. vfs tree,
vfs find, vfs mount, vfs import, vcs status and vcs commit all stop
this way.
An entry this version cannot read, whether a newer SDK wrote it or someone
planted it, is hidden from ls, tree and find with a [muhkoo/vfs]
warning, and kept as it is whenever its directory changes. vfs rm -r <path>
removes it. vfs sweep refuses while any exist, because it cannot tell what
they reference. vfs sweep --force goes ahead anyway, and may delete records
that only those entries referenced.
Version control treats its own records the same way: a HEAD, branch or pending
merge it cannot read stops the command with an error that says how to replace
it. A HEAD that cannot be read is replaced by muhkoo vcs switch <branch> --discard, which does not read HEAD first. A pending merge whose record cannot
be read (the merge in progress cannot be read …; abort the merge to clear it)
makes every vcs commit fail until you run muhkoo vcs merge --abort, and
vcs status warns about it while still listing your changes. The same command
gives up on a merge that stopped for conflicts. It deletes only the merge
record and leaves your files as the merge left them, then prints the command
that puts them back: muhkoo vcs switch <branch> --discard on a branch, or
muhkoo vcs checkout <commit> --discard on a detached HEAD, which keeps HEAD
where it was. Add --discard to the abort to have it do that for you. Nothing
else goes with --abort, and a branch name alongside it is refused.
Version control
Section titled “Version control”muhkoo vcs is client.vcs from the terminal: history for a
project in your filesystem. There is no push or pull, because the repository
lives in your account and every machine you sign in from already has it.
muhkoo vfs cd /apps/my-appmuhkoo vcs statusmuhkoo vcs commit -m "first pass"muhkoo vcs log -n 20muhkoo vcs branch experiment && muhkoo vcs switch experimentmuhkoo vcs merge mainThe project comes from your vfs working directory; --project <slug> names
another. -m <message> and -n <count> are the commit message and the number of
log entries. --discard throws away uncommitted changes in switch and
checkout instead of refusing to move.
Syncing a directory
Section titled “Syncing a directory”Two commands put a project directory in step with your filesystem. They share everything below this table: what syncs, the ignore rules and the limits.
vfs import | vfs mount | |
|---|---|---|
| Direction | Upload only | Both ways |
| Runs | Once, then exits | Until you stop it |
| Destination | /apps/<slug> for each directory, unless you pass --path | / unless you pass --path |
| Directories | Several at once | One |
--dry-run | What would upload | What mounting would do, both ways |
Import a project
Section titled “Import a project”muhkoo vfs import ~/code/my-app --dry-run # what would upload, and what import leaves alonemuhkoo vfs import ~/code/my-app # → /apps/my-appmuhkoo vfs import ~/code/web ~/code/api # several projects, one vault unlockmuhkoo vfs import . --path /apps/scratch # one directory, somewhere elseEach directory goes to /apps/<slug>. The slug is the slug field of the
directory’s .muhkoo-app.json, which is the name the portal and the IDE open
the project by; without that file, it is the directory’s own name. --path
names one destination, so it takes one directory.
Import only uploads. New and changed files go up. A file changed on both
sides goes up too, and the other version stays in muhkoo vfs history. With
--allow-delete, a file you deleted here is deleted from your filesystem.
Import never writes or deletes anything on this disk. Differences it leaves
alone are listed with – and a reason, and kept out of the sync state, so a
later mount still brings them down.
The dry run marks which side each change lands on, and writes nothing at all. On a machine that is not paired yet, it signs you in for that one command and pairs nothing:
$ muhkoo vfs import ~/code/my-app --dry-run↑ /src/App.tsx↑ /src/main.tsx! /README.md (changed on both sides — your copy replaces the other, which stays in `vfs history`)– /notes.md (skipped: changed only in your filesystem — import only uploads; `muhkoo vfs mount` brings it down)
3 change(s); 3 file(s) to upload, 12.4 KB.Ignoring per your .gitignore files and .git/info/exclude, as git reads them (…)✕↑ marks a delete from your filesystem, and ✕↓ a delete from this disk,
which only mount makes. If any directory fails, import still tries the rest,
then exits with status 1, so muhkoo vfs import ~/code/my-app && muhkoo deploy
stops where it should. Ctrl-C ends an import at once. That is safe, because its
state files are written by rename, and a re-run records files that already
match on both sides as in sync.
Keep a directory in sync
Section titled “Keep a directory in sync”muhkoo vfs mount ~/code/my-app --path /apps/my-app# Mounting /apps/my-app → /Users/you/code/my-app# ✓ In sync. Watching for changes — press Ctrl-C to stop.A mount pulls the subtree to a local directory, then keeps both ends in step
until you stop it. Folders sync as well as files: an empty folder made on one
side appears on the other, and with --allow-delete a removed folder is removed
on the other side too, deepest first and only when it is empty. A folder that
still holds a file that never syncs is kept, and the mount says so. Folders
follow the same refused-path and ignore rules as files. Edits on disk go up, and changes made anywhere else come
down over the live feed. Every decision is made from a content hash rather than
a timestamp, which is what stops a write echoing back around the loop. Edits
settle for 250 ms before a pass runs, so an editor’s burst of writes is one
pass.
Pass --path. Without it, the root is /, which is every project in your
filesystem. Mount takes one directory, the folder on this computer, and
--path is the Muhkoo path: muhkoo vfs mount ./ --path /apps/my-app. It
creates the folder if it is missing, but not the folders above it. A --path
that is not a Muhkoo path, such as ./, is refused, and arguments that look
swapped get the corrected command. It runs in the foreground, like a dev server. A first pass that left work undone says
so instead of “In sync”, for example ⚠ Not in sync yet: 40 files follow in the next pass. (see How much one pass moves).
To see what a mount would do before it does anything, add --dry-run. It runs
the same scan and plan a pass does, prints them in both directions, and stops,
with no watcher. It writes nothing at all: no state, no ignore rules (nor their
copy under ~/.muhkoo), nothing in your filesystem, and a directory that does
not exist yet is not created. On a machine that is not paired yet it signs you
in for that one command and pairs nothing. A path a mount could not create,
such as one under a file or behind a dangling symlink, is refused rather than
planned. A delete that needs --allow-delete is marked as such.
The plan has no per-pass limits, so for a project past them it shows everything
the mount would do over the passes it runs straight after the first. When an
ignore file from your filesystem cannot be read, a mount uploads nothing until
it can; the dry run marks those uploads held, leaves them out of the count,
and names the file.
$ muhkoo vfs mount ~/code/my-app --path /apps/my-app --dry-runMounting /apps/my-app → /Users/you/code/my-app would:↓ /.gitignore✕↓ /old.txt (delete from this disk) — only with --allow-delete↑ /src/new.ts↓ /src/remote.ts
4 change(s): 2 to download, 1 to upload. Nothing was changed.Ignore files come down first and the rest is planned again with them, so the mount may do less than this.Ignoring per the built-in Node defaults (write a .vcsignore to change them).In 0.14.0-alpha.8, mount does not read --dry-run, and
muhkoo vfs mount <dir> --dry-run mounts for real.
When a file changed on both sides, your local copy wins, because that is where you are working. The other version stays in history:
⚠ conflict on /README.md — kept your local copy. The other version is still there: muhkoo vfs history /apps/my-app/README.mdA mount keeps its state in .muhkoo/ inside the directory. mount.json holds
what was last in sync, ignore-rules.json holds the ignore rules the
directory has used (Loosening the rules), and
content-hashes.json remembers what the mount has already hashed, so a restart
reads only files that are new or changed instead of downloading the tree again
(an unchanged 600-file drive restarts in seconds; the first mount after
upgrading to 0.15.0-alpha.7 still reads everything once). None of them ever
syncs. A content-hashes.json that is missing or unreadable is simply ignored. mount.json only counts for the root it names, so pointing the
directory at another --path starts from scratch. With no state, a pass
records files that are already identical on both sides as in sync, copies
across whatever is on one side only, and deletes nothing. Add .muhkoo/ to
your .gitignore. In a git repository whose .gitignore does not list it,
import reminds you.
Stopping a mount
Section titled “Stopping a mount”Ctrl-C, kill or closing the terminal asks the mount to stop:
Stopping after the current file — press Ctrl-C again to quit now.✓ Unmounted.It finishes the step under way, saves its state and exits. The step can be a 100 MB file on a slow link. This works during the first pass too. Scans hand back control every 25 ms, so a stop lands promptly even while a scan is matching thousands of paths against the ignore rules.
A second Ctrl-C quits at once, with exit code 130. That is safe: every file
lands by renaming a temp file into place, so an interrupted write leaves either
the old file or the new one, never half of either. A leftover .muhkoo-tmp-*
file never syncs.
What never syncs
Section titled “What never syncs”Some paths never sync, in either direction. No flag, and no ! rule in any
ignore file, changes that. They are refused before the ignore rules are read,
on every segment of the path.
| Refused | Names |
|---|---|
| Directories | .git .ssh .muhkoo .vscode .idea .config node_modules .husky .claude .cursor .zed .fleet |
| Files | .envrc .npmrc .netrc .gitconfig .pam_environment .xprofile .profile .bashrc .bash_profile .bash_login .bash_logout .zshrc .zshenv .zprofile .zlogin .zlogout .kshrc |
Each of these either holds credentials or can run code without an execute bit.
Git runs core.pager and core.fsmonitor from .git/config, shells source
their startup files, .husky is git’s hooks path, and editor and agent folders
hold tasks and hooks that run when a project opens. Names are compared the way
a volume that folds case and Unicode might open them, so Node_Modules,
.GIT, .git., .ßh and GIT~1 are refused on every platform. A refused path
arriving from your filesystem is reported, for example
refusing /.git/config from your filesystem — that path can execute code on this machine.
These are also left out:
- Dot-files, unless you pass
--hidden. Files named exactly.vcsignoreor.gitignoreare the exception, and sync without the flag unless they sit inside a dot-directory. A machine collects tool folders nobody could list in advance, so dot-entries are treated as machine state until you say otherwise. With--hiddenthey sync, apart from the refused names above. - Ignore files under another spelling, such as
.VCSIGNORE,.GitIgnoreor.gitignore., which a folding volume would open as the real file. - Files over 100 MB. For a file coming down, a directory record that claims more is skipped before anything is fetched, and the bytes that actually arrive are checked again. So a record’s claimed size can only get a file skipped sooner; a record claiming a small size cannot carry a large file past the limit.
- Symlinks, sockets and fifos.
- Your home directory, and anything that contains it.
mount,importandvfs ignore --acceptrefuse the filesystem root, your home directory and any directory above it. The check compares directories, not spellings, so macOS’s/System/Volumes/Data/Users/youis refused too. There is no override. A home mount would upload everything in it, and a remoteLibrary/LaunchAgents/x.plistwould land where it runs at login.
Skipped is not deleted. A path that either side deliberately did not look
at is left alone, and never read as gone. That covers paths that are refused,
too big, unreadable, too costly to match against the ignore rules, or not read
yet this pass. An unreadable remote file warns could not read … — leaving it alone, and the pass goes on.
On a volume that folds case, two remote paths that would land on one local file,
such as Notes.md and notes.md, are refused. The exceptions: if exactly one
spelling is already on disk, that one syncs, and if every spelling is, they all
do. On Windows only, names Windows cannot hold as themselves are refused when
written: names containing :, a backslash or <>"|?*, names ending in a dot
or space, and device names such as CON or nul.txt.
Ignore rules
Section titled “Ignore rules”Ignore files decide what syncs, in the same syntax as .gitignore:
- A root
.vcsignorewins outright. It is read alone, on top of the built-in defaults, and no.gitignorecounts. Use one when what should sync differs from what git tracks. Git ignores output because it can be rebuilt; a sync may also want to skip files that are committed but pointless to copy, such as lockfiles and large fixtures. Only the.vcsignoreat the root is read. - Without one, git’s rules apply in full. Those are the defaults, then
.git/info/exclude, the root.gitignore, and every nested.gitignorescoped to its own directory, shallowest first. The last match wins, so a deeper file overrides a shallower one. A.gitignoreinside a dot-directory counts only with--hidden.
As in git, nothing under an excluded directory can be re-included, and the scan
never looks inside one. Negating a directory (!keys/ after *.pem) keeps the
directory, but not files in it that another rule excludes.
The built-in defaults sit underneath either set of rules: node_modules/,
dist/, build/, out/, coverage/, .next/, .nuxt/, .svelte-kit/,
.turbo/, .parcel-cache/, .vite/, *.tsbuildinfo, *.log,
npm-debug.log*, yarn-debug.log*, yarn-error.log*, .DS_Store,
.muhkoo/, .env and .env.*, with .env.example kept. Your own rules can
negate them; !dist/ syncs a built site.
On a volume that folds case, rules match case-insensitively, so
config/master.key also covers Config/Master.key. The mount checks the
volume the way git sets core.ignorecase. On every volume, rules ignore
differences in Unicode normalization. A line that cannot be read, such as
[z-a], is skipped with a warning, and the rest of the file still applies.
To turn your git rules into a .vcsignore:
muhkoo vfs ignore ~/code/my-app --draft # print one; write nothingmuhkoo vfs ignore ~/code/my-app --write # save it as .vcsignoremuhkoo vfs ignore ~/code/my-app --write --force # replace the one already thereA .vcsignore replaces every other ignore file, so the draft carries all of
them over as they are on disk. It starts from the defaults, then adds
.git/info/exclude, the root .gitignore, and each nested .gitignore
re-anchored under its directory. A draft too large to be read as an ignore
file (over 256 KB or 10,000 rules, which a flattened monorepo can reach) is not
written.
Loosening the rules
Section titled “Loosening the rules”Once a path has been kept home, no edit to an ignore file lets it sync. It makes no difference whether the edit was pulled or made by you, or whether it deletes a rule or only adds a blank line. Only an explicit accept lets the path through. New exclusions apply at once, and a loosening waits.
Ignore files travel with the project, so anything that can write your
filesystem can write them. If a pulled .vcsignore could un-ignore
config/master.key, the next pass would upload it. The sync also cannot tell a
pulled file from your own edit of one: two earlier designs tried, and each was
fooled by one ordinary local edit to a file that had arrived from elsewhere. So
it does not ask where a file came from.
Instead, .muhkoo/ignore-rules.json keeps every set of ignore rules the
directory has used since the last accept. A path is ignored if any of those
sets, or the files as they now stand, ignore it, and the same test decides both
directions. Comments, blank lines, line endings and switching back to a set
already kept (a branch switch, say) add nothing. Any other change adds a set.
When earlier rules hold something back, the mount says so once:
⚠ 1 path is held back — the ignore rules as they now stand would sync it, but rules this directory used earlier (when last accepted, or since) exclude it: notes.txt Nothing is synced for it until you accept the ignore files as they now stand. See what that would let through: muhkoo vfs ignore /Users/you/code/my-app --acceptReview, then accept:
muhkoo vfs ignore ~/code/my-app # is anything held back, and which ignore files changed?muhkoo vfs ignore ~/code/my-app --accept # what accepting lets through, and a digestmuhkoo vfs ignore ~/code/my-app --accept 9b1f0c4e2a7d3856 # accept exactly the files the preview showedThe preview lists the ignore files changed since the last accept, and the local
files accepting would release. Read the changed files first: one that came
from your filesystem may not be yours. Then it prints a digest of the ignore
files as they now stand, with the command that accepts exactly those files. On
a terminal it then asks Accept them now? [y/N], and a yes accepts exactly the
files it showed.
Either way, the accept is bound to what you saw. An accept adopts whatever
ignore files are on disk, including one that has just arrived. So if an ignore
file changes after the preview, --accept <digest> refuses and accepts
nothing. There is no --yes. A running mount acts on an accept at once.
vfs ignore works locally, with no sign-in or network, and defaults to the
current directory.
It fails closed. A pass stops and syncs nothing when the stored rules pass
64 sets (63 changes since the last accept) or 2 MB, or when
ignore-rules.json cannot be read. The error names the ignore files changed
since the last accept; review them and accept. --accept still prints a digest
when the stored rules cannot be read, and that is the way out. Ignore files
from your filesystem count toward those limits, so a remote party can force
that review, but it can never make a file leave the machine.
An ignore file of your own over 256 KB or 10,000 rules also stops the pass, and the error names it, because reading only part of it could drop exclusions. One from your filesystem that size is not pulled.
Losing .muhkoo/ does not reset any of this. That happens, for example, when
git clean -fdX removes a git-ignored .muhkoo/. A copy of the rules is kept
under ~/.muhkoo/mounts/, keyed by the directory and the remote root, and the
next pass puts it back.
Deletes
Section titled “Deletes”Deletes are off by default, in both directions. An overwrite can be
undone, because the old version stays in muhkoo vfs history, but a delete
removes the file’s history with it. Without --allow-delete, the mount warns
and stops tracking the path, and the next pass copies the file back from the
side that still has it:
⚠ /old-notes.md is gone locally but still in your filesystem — not deleting it. Pass --allow-delete to propagate deletions, or remove it with: muhkoo vfs rm /apps/my-app/old-notes.mdWith --allow-delete, deletions carry across, marked ✕↑ for your filesystem
and ✕↓ for this disk. The exception is a file the other side changed since
the last sync: the newer copy comes back instead, since honouring a stale
delete would destroy newer work.
A breaker holds back mass deletion, judged separately in each direction. A pass that would delete more than 3 files, leaving fewer than half of the most this run has tracked, deletes none of them in that direction. So does a pass that would delete anything when the other side holds no files. It says why:
⚠ refusing to delete 212 of 230 files from /Users/you/code/my-app in one pass — they are gone from /apps/my-app, which looks like a missing or renamed root, not an edit. Nothing was deleted. If you meant it, delete them here yourself; to upload them all again instead, remove /Users/you/code/my-app/.muhkoo/mount.json.This means the last file of a project has to be deleted by hand, with
muhkoo vfs rm. No folder is removed in a pass where the breaker holds the file
deletes.
One case is not held. If you empty the mount folder completely (no files and no
folders, apart from .muhkoo/ and files that never sync, like .DS_Store), the
mount treats it as a fresh start: everything downloads again and nothing is
deleted remotely. To delete the files from your filesystem instead, use
muhkoo vfs rm -r <path>. A partly emptied folder, or a remote side that comes
back empty, is still held.
How much one pass moves
Section titled “How much one pass moves”A mount bounds what one pass may pull, read and write, so that nothing it downloads can fill your disk or keep it busy forever:
| Limit | Value | Past it |
|---|---|---|
| One file | 100 MB | Skipped in both directions, and never deleted |
| Pulled in one pass | 2 GiB or 10,000 files | The rest follow in a pass that starts at once |
| Read to compare in one pass | 2 GiB or 10,000 files | The rest are compared next pass, and left alone until then |
| Free space kept on the volume | 5% of it, at least 1 GiB, at most 10 GiB | Pulls wait until there is room |
| One ignore file | 256 KB or 10,000 rules | Yours stops the pass; one from your filesystem is not pulled |
Many entries can share one stored file, because a copy moves no bytes. Without a pull budget, one 100 MB file and a million cheap entries pointing at it would make a single pass write it a million times. Content is keyed by its manifest, so a running mount never reads again what it has already read, and a steady pass reads nothing. The reserve is capped because 5% of a 1 TB disk is 50 GB, and many laptops live below that; if the platform cannot report free space, the pass budget still bounds a pass. A directory record’s claimed size is checked first, so a pull or read that cannot fit is not fetched at all, and then the bytes that actually arrive are checked again. A record’s claimed size can only get a file skipped or held sooner. Nothing is let through on the size a record claims, because whoever writes the record chooses it.
Ignore files come down first in a pass, which is then planned again under them,
so a project’s .gitignore applies in the pass that brings it. If one cannot
come down (held for space or by the budget, unreadable, or too large to be read
as rules),
that pass holds its uploads while pulls go on, and says so:
not uploading 3 changes this pass (…): ignore rules from your filesystem have not come down here, and they may keep some of these files home.
Import never pulls, and reads everything it compares in one go, so of these only the 100 MB file limit and the ignore-file limit apply to it.
Why mount distrusts what it downloads
Section titled “Why mount distrusts what it downloads”Any app you sign into holds your master seed. Hosted sign-in hands the seed to the app (see where the seed lives), so any of those apps can write any path, name and bytes into your filesystem. None of them can touch your disk directly. A mount is the bridge between the two, so it treats everything it reads from your filesystem as untrusted input, and passes all of it through three checks:
- One admission rule decides what syncs. It is applied the same way in both directions, and by the watcher. When the two scans filtered differently, the sync read each difference as a deletion.
- One write boundary covers every write and delete on disk. Each folder on
the way that already exists must be a real directory, not a symlink, listed
under exactly that spelling. So a remote
src/App.tscannot land on your unsyncedsrc/app.ts, and a symlinked folder cannot redirect a write outside the mount. A file already on disk is replaced only if the sync tracks it. Anything elseexists here but is not synced (ignored, hidden or refused), so it may exist nowhere else. A replaced file keeps its permission bits, and a new one never gets an execute bit. - The ignore ratchet (Loosening the rules) stops any ignore file, pulled or yours, from widening what leaves the machine.
What this does not cover:
- Ordinary project files still sync both ways. That includes source,
bin/and package-manager configs, so an app holding your seed can change code that you later run. - Anything that reaches your filesystem can be read by every app you sign into. That is why the refusals and the ratchet concentrate on keeping files home.
Both follow from apps holding the seed. A fix, in which apps propose changes that only your own client writes and each app gets its own keys, is designed but not built.
Upgrading an existing mount
Section titled “Upgrading an existing mount”If you mounted or imported with 0.14.0-alpha.8 or earlier:
- If you used
--hidden, check what it uploaded. Earlier versions refused paths like.gitonly on the way down, so--hiddenuploaded.git/(including a remote URL with any embedded token),.npmrc,.netrc,.envrc,.ssh/and.config/. It uploaded.envfiles too if an ignore file, possibly a pulled one, negated the default.envrules. Upgrading does not remove any of them. Look withmuhkoo vfs tree /apps/<slug>(or the root you mounted) and remove them withmuhkoo vfs rm -r. Then rotate any token they held, because every app holding your seed could read them. - Read your ignore files before the first run. The first set of rules kept
is whatever is on disk the first time this version runs, before anything is
pulled, so a file planted earlier would be taken as yours. Look over every
.vcsignoreand.gitignore, nested ones included, for lines you did not write, and for spellings like.VCSIGNOREor.GitIgnore. - The first mount compares from scratch. A mount stopped under 0.14.0-alpha.8 saved its state without the root, so it is not trusted. Identical files are recorded as in sync, and files on one side only are copied across. Files that differ are conflicts, and your local copy wins. Nothing is deleted.
vfs importno longer downloads, or deletes anything on this disk.vfs ignore <dir>on its own now shows what earlier rules hold back. The drafted.vcsignoreit used to print is now--draft.- With
--hidden,.vscode/,.idea/and.config/no longer upload, and no rule can bringnode_modulesback in.
Command reference
Section titled “Command reference”| Area | Commands |
|---|---|
| Account | login · logout · whoami · devices ls|rm |
| Apps | apps ls|get|create|slug|rm · keys rotate · tokens ls|create|revoke |
| Backend | provision · tables ls|get|rm · agents ls|get|rm|enable|disable|models · functions ls|get|code|deploy|rm|enable|disable |
| Hosting | deploy · promote · hosting status|rollback|rm-release|unpublish · domains ls|add|rm |
| Files | vfs cd|pwd|ls|stat|tree|cat|put|get|mkdir|rm|cp|mv|find · vfs history|restore · vfs import|mount|ignore · vfs use-app|app · vfs sweep · kv ls |
| Version control | vcs status|commit|log|show|diff|branch|switch|checkout|merge|restore · vcs merge --abort (from 0.14.0-alpha.9) |
| Tools | logs · eject |
Global options on every command: --base, --token, --json where output is
structured, and -h/--help. -v/--version prints the CLI version.