Skip to content

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.

npm install -g @muhkoo/cli

Requires Node.js 20+. Verify with muhkoo --version.

Two ways to authenticate; both store the resulting session token in ~/.muhkoo/config.json.

Terminal window
muhkoo login --web

Opens 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.

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:

Terminal window
muhkoo whoami # base, email and plan (--json for machine-readable)
muhkoo logout # clears the stored token; keeps your default --base

A provision spec is a JSON file describing your backend. The CLI applies it idempotently — re-running updates tables, agents, and functions in place.

app.json
{
"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'); } }" } ]
}
Terminal window
muhkoo provision --spec app.json

This 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.

Terminal window
muhkoo provision --spec app.json --dry-run # preview the API calls, change nothing
muhkoo provision --spec app.json --enable # enable agents/functions on their channels
muhkoo 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.

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:

app.template.json
{
"slug": "team-standup",
"functions": [
{ "name": "notify", "code": "export default { async fetch() { /* uses __SLACK_TOKEN__ */ } }" }
]
}
Terminal window
# Substitute, provision, then discard the generated spec.
sed "s/__SLACK_TOKEN__/$SLACK_TOKEN/" app.template.json > app.json
muhkoo provision --spec app.json

Because 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.

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).

Terminal window
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.dev

deploy 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:

Terminal window
muhkoo deploy --app "$MUHKOO_APP_ID" --key "$MUHKOO_DEPLOY_KEY"

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:

Terminal window
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):

Terminal window
muhkoo promote # copy the test release + functions onto prod

promote 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.

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:

.github/workflows/deploy.yml
name: Deploy
on:
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 key

To 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.

--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).

Terminal window
muhkoo apps ls --base staging
muhkoo deploy --base local # http://localhost:8787
muhkoo whoami --base https://api.example.com
Terminal window
muhkoo apps ls # your apps
muhkoo apps get <appId> # details + key metadata + hosting URL
muhkoo 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 keys
muhkoo keys rotate <appId> # rotate all four keys (revokes the old set)
muhkoo deploy # publish dist/ to the test env
muhkoo promote # promote test → production, in place
muhkoo hosting status <appId> # releases + current pointer
muhkoo hosting rollback <appId> --release <id> # instant rollback
muhkoo hosting rm-release <appId> <releaseId> # delete one stored release
muhkoo 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 models
muhkoo 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 spaceId
muhkoo 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.

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.

Terminal window
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.

Most flags have an environment-variable equivalent, so CI never needs interactive input:

VariableEquivalent flagPurpose
MUHKOO_DEV_TOKEN--tokenDeveloper session token — for provision and account/app management in CI
MUHKOO_DEPLOY_KEY--keyApp secret key (mk_*_sk_*) — authorizes deploy
MUHKOO_APP_ID--appThe app id, when not running from a provisioned app dir
MUHKOO_API_BASE--baseAPI base: prod (default), staging, local, or a literal URL
MUHKOO_USERNAME, MUHKOO_PASSWORD--username, --passwordCredentials 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.

A server, CI job or script authenticates with an access token rather than a user sign-in — scoped to what it needs, and expiring.

Terminal window
muhkoo tokens create <appId> --scopes db:read,db:write --expires-in 30
muhkoo tokens create <appId> --env live --label ci-reader # --env test|live, --label names it
muhkoo 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.

muhkoo vfs is client.vfs from the terminal — the same code the SDK runs, not a reimplementation over HTTP.

Terminal window
muhkoo vfs ls / # first run pairs this machine
muhkoo vfs cd /apps/my-app
muhkoo vfs cat src/App.tsx
muhkoo vfs find '**/*.ts'
muhkoo vfs history src/App.tsx
muhkoo vfs restore src/App.tsx 0

cd 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.

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.

Terminal window
muhkoo devices ls # machines paired to your account
muhkoo devices rm <id> # withdraw one machine's access, from anywhere

Revoking works because the key here is useless without the ciphertext there — which is exactly what a stored seed could never offer.

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:

Terminal window
muhkoo vfs use-app my-app.apps.muhkoo.dev

The 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.

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.

Terminal window
muhkoo kv ls todos/ # one client.kv collection
muhkoo kv ls vfs/ --json # your filesystem's records, as a JSON array

When 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.
Terminal window
muhkoo vfs rm -r /apps/my-app/junk

A 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.

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.

Terminal window
muhkoo vfs cd /apps/my-app
muhkoo vcs status
muhkoo vcs commit -m "first pass"
muhkoo vcs log -n 20
muhkoo vcs branch experiment && muhkoo vcs switch experiment
muhkoo vcs merge main

The 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.

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 importvfs mount
DirectionUpload onlyBoth ways
RunsOnce, then exitsUntil you stop it
Destination/apps/<slug> for each directory, unless you pass --path/ unless you pass --path
DirectoriesSeveral at onceOne
--dry-runWhat would uploadWhat mounting would do, both ways
Terminal window
muhkoo vfs import ~/code/my-app --dry-run # what would upload, and what import leaves alone
muhkoo vfs import ~/code/my-app # → /apps/my-app
muhkoo vfs import ~/code/web ~/code/api # several projects, one vault unlock
muhkoo vfs import . --path /apps/scratch # one directory, somewhere else

Each 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.

Terminal window
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-run
Mounting /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.md

A 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.

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.

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.

RefusedNames
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 .vcsignore or .gitignore are 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 --hidden they sync, apart from the refused names above.
  • Ignore files under another spelling, such as .VCSIGNORE, .GitIgnore or .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, import and vfs ignore --accept refuse the filesystem root, your home directory and any directory above it. The check compares directories, not spellings, so macOS’s /System/Volumes/Data/Users/you is refused too. There is no override. A home mount would upload everything in it, and a remote Library/LaunchAgents/x.plist would 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 files decide what syncs, in the same syntax as .gitignore:

  • A root .vcsignore wins outright. It is read alone, on top of the built-in defaults, and no .gitignore counts. 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 .vcsignore at 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 .gitignore scoped to its own directory, shallowest first. The last match wins, so a deeper file overrides a shallower one. A .gitignore inside 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:

Terminal window
muhkoo vfs ignore ~/code/my-app --draft # print one; write nothing
muhkoo vfs ignore ~/code/my-app --write # save it as .vcsignore
muhkoo vfs ignore ~/code/my-app --write --force # replace the one already there

A .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.

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 --accept

Review, then accept:

Terminal window
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 digest
muhkoo vfs ignore ~/code/my-app --accept 9b1f0c4e2a7d3856 # accept exactly the files the preview showed

The 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 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.md

With --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.

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:

LimitValuePast it
One file100 MBSkipped in both directions, and never deleted
Pulled in one pass2 GiB or 10,000 filesThe rest follow in a pass that starts at once
Read to compare in one pass2 GiB or 10,000 filesThe rest are compared next pass, and left alone until then
Free space kept on the volume5% of it, at least 1 GiB, at most 10 GiBPulls wait until there is room
One ignore file256 KB or 10,000 rulesYours 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.

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.ts cannot land on your unsynced src/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 else exists 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.

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 .git only on the way down, so --hidden uploaded .git/ (including a remote URL with any embedded token), .npmrc, .netrc, .envrc, .ssh/ and .config/. It uploaded .env files too if an ignore file, possibly a pulled one, negated the default .env rules. Upgrading does not remove any of them. Look with muhkoo vfs tree /apps/<slug> (or the root you mounted) and remove them with muhkoo 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 .vcsignore and .gitignore, nested ones included, for lines you did not write, and for spellings like .VCSIGNORE or .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 import no longer downloads, or deletes anything on this disk.
  • vfs ignore <dir> on its own now shows what earlier rules hold back. The drafted .vcsignore it used to print is now --draft.
  • With --hidden, .vscode/, .idea/ and .config/ no longer upload, and no rule can bring node_modules back in.
AreaCommands
Accountlogin · logout · whoami · devices ls|rm
Appsapps ls|get|create|slug|rm · keys rotate · tokens ls|create|revoke
Backendprovision · tables ls|get|rm · agents ls|get|rm|enable|disable|models · functions ls|get|code|deploy|rm|enable|disable
Hostingdeploy · promote · hosting status|rollback|rm-release|unpublish · domains ls|add|rm
Filesvfs 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 controlvcs status|commit|log|show|diff|branch|switch|checkout|merge|restore · vcs merge --abort (from 0.14.0-alpha.9)
Toolslogs · eject

Global options on every command: --base, --token, --json where output is structured, and -h/--help. -v/--version prints the CLI version.