Infrastructure

Mops CLI

Manage Motoko projects with the mops CLI — toolchain pinning, dependency management, type-checking, building, and linting. Use when working with mops.toml, mops.lock, running mops commands, adding/removing packages, pinning moc or lintoko versions, checking or building canisters, configuring moc flags, or setting up a new Motoko project.

Skill ID
mops-cli
Category
Infrastructure
License
Apache-2.0
Compatibility
mops >= 3.1.0
Last updated
Source

Trust note.This page is a static, pre-rendered mirror ofdfinity/icskills/skills/mops-cli/SKILL.md. The canonical source is the Git commit it was built from. Licensed Apache-2.0.

Mops CLI

Opinionated guide for Motoko projects. Covers project config, dependency management, type-checking, building, and linting.

Key Principles

  1. No dfx — mops neither invokes nor supports dfx. There is no mops toolchain init, no moc-wrapper, and no mops watch --generate / --deploy. Always pin moc in [toolchain]; every command that compiles requires it. Use the newest moc version. Pin pocket-ic too if you have replica tests, benchmarks, or --check-deploy — with no pin those commands error, naming mops toolchain use pocket-ic 15.0.0. When MOPS_POCKET_IC_URL points at an already-running PocketIC server, no pin is needed and an existing pin is ignored with a warning — do not add one to silence it; unset the variable instead.
  2. No mo:base — it is deprecated. Always use mo:core (import Array "mo:core/Array").
  3. All config in mops.toml — canisters, moc flags, toolchain versions, build settings.
  4. Canister-centric workflow — define all canisters in [canisters]; never pass file paths to mops check. Exception: library packages (no [canisters]) use file paths directly: mops check src/**/*.mo.

Project Setup

Minimal mops.toml

[toolchain]
moc = "1.7.0"
lintoko = "0.10.0"
pocket-ic = "15.0.0"  # required for replica tests / benchmarks / --check-deploy

[dependencies]
core = "2.5.0"

[moc]
args = ["--default-persistent-actors", "-W=M0223,M0236,M0237"]

[canisters.backend]
main = "src/backend/main.mo"

[canisters.backend.migrations]
chain = "src/backend/migrations"
check-limit = 10   # optional — speeds up `mops check` when the chain gets long

[canisters.backend.check-stable]
path = "deployed/backend.most"

[build]
outputDir = "src/backend/dist"
args = ["--release"]
check-wasm = true    # optional: analyze final Wasm complexity
check-deploy = true  # optional: verify fresh PocketIC installation after build

# Opt-in Wasm optimization (Binaryen wasm-opt) for build + bench
[optimize]
# level = "O3"       # default
# keep-names = true  # default
# wasm-opt pin: [toolchain] wasm-opt = "131" (required when [optimize] is set)

check-stable runs ICP’s upgrade-time stable-variable compatibility check locally, so incompatible changes fail in mops check instead of being rejected when upgrading a live canister. It compares the current code against a .most from the deployed version.

Bootstrap that .most: new project → mops deployed init (empty-actor baseline); already-deployed canister → build from the deployed commit, then mops deployed. After every deploy, run mops deployed to promote the just-built .most (see mops deployed below).

Optional canister fields: candid (path to .did for compatibility checking), initArg (Candid-encoded init args).

Warning Flags

-W=M0223,M0236,M0237 — redundant type instantiation (M0223), suggest contextual dot notation (M0236), suggest redundant explicit arguments (M0237). These are allowed (disabled) by default; -W= enables them as warnings.

Moc Args Layering

Flags are applied in this order (later overrides earlier):

  1. [moc].args — global, all commands (check, build, test, bench, etc.)
  2. [build].args — build only (e.g. --release)
  3. [canisters.<name>.migrations] — auto-injected --enhanced-migration (managed by mops)
  4. [canisters.<name>].args — per-canister
  5. CLI -- <flags> — one-off overrides; supported by mops check, mops build, mops check-stable, mops generate, mops migrate, mops test, and mops bench

Core Commands

mops install

mops install            # dev flow: keeps mops.lock in sync, self-heals a broken one
mops install --locked   # CI flow: fail if mops.lock is missing or would change

Run after cloning or after manual mops.toml edits. mops.lock is always maintained — there is no flag to opt out, and no --lock flag (removed in v3). A missing, unparseable, legacy-format or mops.toml-inconsistent lock is regenerated by a plain mops install, including locks that still carry absolute local paths from older CLIs.

--locked requires an up-to-date lock and never writes it. It is also available on every implicitly-installing command (mops build, mops check, mops check-stable, mops check-candid, mops test, mops bench, mops generate candid), so CI can run mops test --locked with no prior install. mops sources has no --locked (a packtool caller parses its stdout mid-build) — put mops install --locked earlier in the pipeline instead.

The CI env var no longer affects lockfile behavior (removed in v3). Commit mops.lock — for applications and libraries alike.

A lock also goes stale when a local path dependency’s own mops.toml changes — including one nested further down the chain. mops install regenerates it; --locked fails until the regenerated lock is committed. Note the first mops install after upgrading regenerates the lock of any project that has a path dependency, so commit it before running --locked in CI. Projects without path dependencies keep their existing lock.

{MOPS_ENV} in a path dependency expands to $MOPS_ENV (default local), which makes the lock environment-specific. Switching MOPS_ENV makes it stale: mops install regenerates it, and --locked fails with a message naming MOPS_ENV. Keep one lock per environment, or drop --locked — a lock generated under one MOPS_ENV will not satisfy --locked under another.

Integrity is verified at download time, so mops install no longer re-hashes .mops/: editing a dependency in place will not fail the next install. Use mops verify for the on-demand on-disk audit.

Downloaded files are always checked before anything enters the cache — against mops.lock when it already records the package, otherwise against the registry. A committed lock therefore makes verification free, which is why a clean checkout installs without asking the registry about hashes.

Two consequences worth knowing: a corrupt or hand-edited mops.lock now fails a download (the error names mops.lock as a possible culprit — restore it from version control; already-cached packages are unaffected), and a package the registry publishes no hashes for still installs, unverified, with a warning.

Packages download in parallel through a bounded pool. mops install --concurrency <n> or the MOPS_CONCURRENCY env var (works on every installing command) caps simultaneous registry requests; the default derives from the CPU count and the file-descriptor soft limit (4–16). Transient network errors (fetch failed, ECONNRESET, EMFILE) retry automatically with the concurrency halved, up to twice. Set MOPS_CONCURRENCY=1 only if installs still fail after the retries (an egress proxy capping connections, for example).

mops verify

mops verify   # re-hash .mops/ against mops.lock, and mops.lock against the registry

Covers GitHub dependencies as well as registry ones.

Exits 1 with the offending files and a recovery hint. This is the replacement for anyone who relied on mops install failing when .mops/ had been modified.

mops add <package>

mops add core             # latest version
mops add core@2.5.0       # specific version
mops add --dev test       # dev dependency
mops add org/repo         # GitHub shorthand (also accepts a full github.com url)
mops add ./pkg            # local package directory

Updates mops.toml and mops.lock.

Adding a package that is already declared in the other section moves it rather than declaring it twice. <pkg>@<version> replaces the declared version and reports what it replaced; it leaves pinned aliases like "core@1.0.0" = "1.0.0" alone, and there is no flag to create one — write it by hand.

mops check

Primary correctness command — runs moc check, then check-stable (if configured), then lint (if lintoko is in toolchain).

On moc 1.12.0+, canisters with [migrations] get stricter upgrade diagnostics: a field the initial actor requires that no migration produces fails as an M0267 error instead of only warning (M0254), and compat errors carry a source location. Temporarily disabledmoc --stable-baseline is buggy, so every pin runs the pre-1.12.0 check. Older moc pins and canisters without [migrations] are unaffected either way.

The check-stable baseline is always a .most file — as [canisters.<name>.check-stable].path or as the mops check-stable <baseline.most> argument. A .mo source is rejected. See mops deployed for where the baseline comes from — that differs between a fresh project and an already-deployed canister.

mops check                # all canisters
mops check backend        # single canister
mops check --fix          # autofix + check + stable + lint
mops check --no-lint      # skip the lint step for one run
mops check --verbose      # show moc invocations
mops check -- -Werror     # treat warnings as errors

Always use canister names, not file paths. Per-canister args from mops.toml are applied automatically.

--fix applies machine-applicable fixes from both moc and lintoko in one pass. Concurrent --fix runs (across processes) serialize automatically via an advisory lock at .mops/fix.lock — safe to invoke from multiple agents on the same project. Read-only files (e.g. frozen migrations) are skipped with a warning, not fixed.

mops build

mops build                # all canisters
mops build backend        # single canister
mops build --verbose      # show compiler commands
mops build --check-wasm   # analyze final Wasm complexity without PocketIC
mops build --no-check-wasm # skip configured [build].check-wasm once
mops build --check-deploy  # verify fresh installation on PocketIC
mops build --no-check-deploy # skip configured [build].check-deploy once
mops build -- --ai-errors # pass extra moc flags

Produces .wasm, .did, and .most files in [build].outputDir (default .mops/.build).

With [optimize] in mops.toml, runs wasm-opt after candid metadata (default -O3 -g). Requires a Binaryen pin: mops toolchain use wasm-opt 131. Build commands never write the pin themselves, and a wasm-opt failure fails the build. Pass --no-optimize (on build or bench) to skip the pass for a single run without editing mops.toml.

When --check-wasm or [build].check-wasm = true is enabled, Mops runs fast Walrus analysis on the final Wasm without starting PocketIC. Per-function IC0505 complexity below 750,000 is quiet, 750,000 through 899,999 emits an early warning, and 900,000 or more emits a critical warning. MOPS-WASM-COMPLEXITY output includes actionable function metrics, the three largest complexity contributors, and Motoko correction guidance. The estimate never fails the build. Use --no-check-wasm to skip configured analysis once.

mops deployed

Post-deploy hook — keeps the on-disk .most baseline used by check-stable in sync with what’s actually deployed.

mops deployed init backend   # one-time bootstrap: empty-actor baseline + sets [check-stable].path
mops deployed backend        # post-deploy: promotes .mops/.build/backend.most → deployed/backend.most
mops deployed                # all canisters

Default destination is deployed/<name>.most; override with [deployed].dir in mops.toml or --dir. It reads built .most files from [build].outputDir (default .mops/.build); override with --build-dir. mops deployed errors if the source .most is missing — it never regenerates. Run it from your deploy pipeline immediately after a successful deploy.

mops generate candid

mops generate candid                # all canisters
mops generate candid backend        # single canister
mops generate candid backend -o <path>   # single canister, ad-hoc path

(Re)generates the curated .did from current Motoko source. With [canisters.<name>].candid set, overwrites that file. Without it, writes <name>.did next to main (e.g. main = "src/Backend.mo"src/backend.did) and sets [canisters.<name>].candid in mops.toml. Run after every interface change; commit .did + mops.toml together. Same moc invocation as mops build, so the result always passes mops build’s subtype check.

mops toolchain

mops toolchain use moc 1.7.0         # pin specific version
mops toolchain use moc latest        # pin latest version (non-interactive)
mops toolchain use lintoko 0.10.0    # pin specific version
mops toolchain use pocket-ic 15.0.0  # pin for replica tests / benchmarks / --check-deploy
mops toolchain use wasm-opt 131      # Binaryen for [optimize] (or `latest`)
mops toolchain update moc            # update to latest (requires existing [toolchain] entry)
mops toolchain update                # update all tools to latest
mops toolchain info <tool>           # show release info (latest, pinned, history)
mops toolchain info <tool> --versions # list recent stable releases, newest first
mops toolchain info <tool> --versions --all # full stable history (cache warming)
mops toolchain bin moc               # print path to binary

pocket-ic versions: pin anything from 9.0.0 up, latest included — mops keeps no list of blessed versions. Pins below 9.0.0 error with a migration message (they ran on the legacy client that mops 3.0.0 removed). With no pin, replica tests / mops bench / --check-deploy / mops toolchain bin pocket-ic error naming mops toolchain use pocket-ic 15.0.0. That version is a hint, not a fallback.

Agent note: toolchain use <tool> without a version opens an interactive picker — do not use in scripts or agents. Always pass a version or latest. toolchain update only works when the tool already has a [toolchain] entry. toolchain info <tool> --versions works without mops.toml (first GitHub page by default; pass --all for full history).

Enhanced migrations

When [canisters.<name>.migrations] is configured, mops check, mops build, and mops check-stable automatically inject --enhanced-migration. Do not add --enhanced-migration to [canisters.<name>].args — mops will error.

Create migration files directly in the chain directory.

After mops check --fix (or mops check <canister>) confirms the chain compiles, run mops build to produce the wasm artifact.

Use mops build --check-deploy, or set [build].check-deploy = true for every build, to install each built Wasm on a fresh PocketIC canister and catch module validation, initialization, and installation failures. Requires [toolchain] pocket-ic (a version from 9.0.0 up, or a local binary path) — or set MOPS_POCKET_IC_URL to an already-running PocketIC server (the pin is then ignored). Unpinned with no URL, the build errors naming mops toolchain use pocket-ic 15.0.0. Use --no-check-deploy to skip configured validation once. The command uses each canister’s initArg, or () when omitted. Set wasmMemoryLimit to a positive integer byte limit on a canister to check deployment under that limit. PocketIC errors are reported as provided by the client, and installation failures are collected across canisters. Before installation, Mops runs moc --stable-compatible from a temporary empty-actor .most to each generated .most. If moc reports incompatibility, Mops emits MOPS-CHECK-DEPLOY-SKIPPED with the compiler diagnostic and does not check that canister on fresh PocketIC. Eligible siblings are still checked; validate the skipped upgrade against representative baseline state.

check-limit (optional) caps how many recent chain files mops check and mops lint consider — useful when the chain grows long and re-checking every old migration slows feedback down. mops build is unaffected by check-limit. When the limit kicks in, mops stages the included files into .migrations-<canister>/ next to the chain directory (auto-.gitignored). moc diagnostics may then print paths there — the real file lives in the chain directory with the same name.

Override check-limit for a single run with --no-check-limit (mops check, mops check-stable, mops lint) — e.g. mops check --fix --no-check-limit to autofix older, normally-trimmed migrations. On mops check and mops check-stable, --no-check-limit also suppresses the pending-migration warning.

When check-limit is set, mops check-stable (and the stable check inside mops check) reports if more migrations are pending than the limit allows — as an error if compat failed (replacing the misleading moc message), otherwise a warning.

mops remove <package>

mops remove core

Removes from whichever section declares the package; --dev limits it to [dev-dependencies]. A package declared in both sections is removed from both.

Dependency Management

mops outdated             # list outdated deps (caret-bound); exit 1 if any, 2 if the check failed
mops outdated core        # check a single package
mops update               # rewrite mops.toml versions within caret bound (no major-version crossing)
mops update core          # update specific package within caret bound
mops update --major       # allow updates that cross major versions
mops update --patch       # restrict to patch bumps only (mutually exclusive with --major)
mops update --verbose     # verbose output
mops sync                 # add missing / remove unused packages
mops sync --dry-run       # print what would change, write nothing

mops update rewrites mops.toml in place (like cargo upgrade, not cargo update) and re-pins GitHub dependencies to their branch head. Like mops outdated, it exits 2 when it cannot run or complete — no mops.toml, a package that is not declared, or a dependency that failed to update (the other dependencies are still updated).

mops sync needs a pinned [toolchain] moc — it reads imports with moc --print-deps. Packages imported only from test/tests/bench/benchmark directories are added to [dev-dependencies]; already-declared packages are never moved between sections.

Other Commands

mops publish

mops publish              # publish to the registry (runs tests/docs/bench by default)
mops publish --dry-run    # same local steps as publish; no registry contact / identity
mops publish --dry-run --no-test --no-docs --no-bench   # packaging checks only
mops publish --no-test --no-docs --no-bench

--dry-run runs the same local publish pipeline (packaging checks, docs, changelog, tests, benchmarks) and prints the final file list, then stops before identity/upload. --no-* flags work as usual. It does not run canister config validation (SPDX/semver/name rules) or prove registry acceptance (already published, permissions, missing deps).

mops test

Tests live in test/*.test.mo:

mops test                         # run all tests
mops test my-test                 # filter by name
mops test --mode wasi             # use wasmtime (for to_candid/from_candid)
mops test --reporter files        # one line per file (default is verbose)
mops test --watch                 # re-run on file changes
mops test -- -Werror              # pass extra moc flags

Replica tests (actor files or // @testmode replica) run on PocketIC — the pocket-ic version from [toolchain], or an already-running server when MOPS_POCKET_IC_URL is set (no pin needed; canister log output is not streamed in attached mode). Unpinned with no URL, they error naming mops toolchain use pocket-ic 15.0.0. Same for mops bench and mops watch --test. There is no --replica flag and no dfx replica.

mops bench

Benchmarks live in bench/*.bench.mo:

mops bench                        # run all benchmarks
mops bench my-bench               # filter by name
mops bench --gc incremental       # select GC
mops bench --save                 # save results to .bench/<name>.json
mops bench --compare              # compare with saved results
mops bench -- -Werror             # pass extra moc flags

mops lint

Runs lintoko (also runs automatically as part of mops check when lintoko is in toolchain):

mops lint                 # lint all .mo files
mops lint --fix           # autofix lint issues
mops lint <name>          # filter to .mo files matching <name>

When [canisters.<name>.migrations].check-limit is set, mops lint skips the trimmed chain migrations to match what moc sees during mops check. To lint a trimmed migration on demand, pass an explicit filter (e.g. mops lint OldMigrationName) or --no-check-limit to lint the full chain.

mops format

mops format               # format all .mo files
mops format --check       # check formatting without modifying

mops watch

mops watch                # errors + warnings + format (the default set)
mops watch --warning      # errors + warnings only
mops watch -t             # errors + tests

Error checking is always on. Passing any flag selects only the named tasks, so add -w / -f when you want them alongside -t. Those four are the whole set — there is no --generate or --deploy.

Common Patterns

Warning suppression for a canister

Use per-canister args (not global) for suppressions:

[canisters.backend]
main = "src/backend/main.mo"
args = ["-A=M0198"]

New project

mops init -y
mops toolchain use moc latest        # pin latest moc (non-interactive)
mops toolchain use lintoko latest    # pin latest lintoko
mops add core

Then configure [moc].args, [canisters], and [build] in mops.toml.

To update tools later: mops toolchain update moc or mops toolchain update (all tools).