A command-line tool for TMC's public API. It creates, edits and deletes your assets, mods, servers, articles, communities, collections and groups — and, because that is what the API is actually used for, it uploads files and cuts releases in one command.
Supports both authentication modes the API accepts: bearer tokens and
Ed25519 signed assertions (JWT mode) — and reading public items with no key at
all (--anon).
No dependencies. Python 3.10+ and the standard library. cryptography is
used for signing if it happens to be installed, and the bundled RFC 8032
implementation is used if it isn't — same signatures either way.
pip install . # gives you `tmc` on PATH
pip install '.[fast]' # …plus `cryptography` for faster JWT signingOr just run it from a checkout — there is nothing to build:
./tmc --help
python src/main.py --helpKeys are managed under Account → API Keys on the site.
# Bearer key (the default kind). Omit --token to be prompted without echo.
tmc auth login --token tmc_xxxxxxxxxxxx
# JWT key: you hold the Ed25519 private key, the site holds only the public half.
tmc auth login --jwt --key-id tmcak_xxxxxxxx --private-key ~/keys/tmc.pem --copy-key
# Several sites or several keys:
tmc auth login --profile staging --base-url https://staging.example.com --token tmc_…
tmc auth use stagingCredentials live in ~/.config/tmc/config.json (mode 0600). TMC_TOKEN,
TMC_KEY_ID, TMC_PRIVATE_KEY_FILE, TMC_BASE_URL and TMC_PROFILE work too,
which is usually what you want in CI.
Check what you have:
tmc auth whoami # which key, and what the server lets it do
tmc auth doctor # crypto backend, config permissions, connectivitywhoami establishes permissions with three deliberately harmless probe requests
(a read, a create that cannot validate, a delete of an empty id list). Nothing is
created or deleted. Pass --read-only to send only the read.
GET on a content item answers with no Authorization header at all, for items
that are completely public. --anon is how you ask for that:
tmc mod get 123 --anon
tmc mod list --anon --app 4 -o json
tmc article list --anon --official -o json # the blogWhat comes back is a summary — id, name, slug, path, description, app,
categories, engagement counts, plus a live server block for servers — not the
record. It is a genuinely different endpoint, so a few things follow:
- Seven types only:
asset,mod,server,community,article,collection,group. Relations, files, comments and reviews always need a key. - Two list filters,
--appand--official, each only on the four types whose model has the column.--search,--tag,--category,--mineare dropped with a warning rather than silently ignored — the server would ignore them, and a filter that did nothing looks exactly like one that matched everything. Both are anonymous-only, and the keyed list warns the same way in the other direction. - Read-only. Any write is refused before the request leaves, since sending it would come back as a 401 that blames a key you did not send.
- Its own quota, counted per source address and deliberately small. A key is free and has a much larger budget.
- Three gates, any of which can turn it off: the site's own switch, the item
being completely public (not hidden, not NSFW, no hidden parent), and the
item's team not having set
apiPublic: false. Only the third is told apart from "no such item" — you get a403 api_disabledrather than a404.
tmc mod update 5 --set apiPublic=false is the other side of that switch.
(Servers are the one type that does not have the field.)
tmc mod list --mine --all # every page of your own mods
tmc mod list --search rust --tag pvp -o json
tmc mod get 42
tmc mod update 42 --set description="Now with fewer bugs"
tmc mod delete 42
tmc open mod 42 --browser # its page on the siteopen prints the page's full address and, with --browser, opens it. The
address is the site's own where it gives one; otherwise it is built from the
record (a keyed record's url field is its slug, not a page). Comments,
reviews, media and releases go through the site's /i/<type>/<id> permalink.
Relative addresses are resolved against the website origin: --site-url,
TMC_SITE_URL, a profile's site_url option, or the base URL minus a leading
api. (api.moddingcommunity.com → moddingcommunity.com).
ls is an alias for list, and get with no id lists as well, so whichever of
the three you reach for first is the one that works. help after a command is
the same as --help on it: tmc mod help, tmc rel add help.
Creating something, with the body read from a file and the icon uploaded on the way through:
tmc mod create \
--set name="My Mod" --set appId=1 \
--content-file README.md \
--icon assets/icon.png \
--tag tooling --tag gamedev--set coerces to the field's real type (the API's schemas are strict and do not
coerce, so appId=1 has to become a number, not "1"). --set-json takes a JSON
literal, --set-file reads a value from disk, and --json supplies a whole body
that --set flags are then applied on top of.
Unknown field names are caught locally, with the near-miss:
$ tmc mod create --set tgs=pvp …
error: 'mod' has no field 'tgs'. Did you mean 'tags'?
Run tmc schema mod for the field list, or tmc schema for everything. Run
tmc contract sync first and both answer from the live site rather than from
this build's mirror — see Staying in step with the site.
This is the part worth having a tool for. release publish uploads the files,
collects their ids, reads the existing release set and writes the release with
everything attached — in the right order, without disturbing your other releases:
tmc release publish --mod 5 \
--version 1.2.0 \
--title "Bug fixes" \
--content-file CHANGELOG.md \
--file 'dist/*.zip' --file dist/checksums.txtRun it again with the same --version and it updates that release, merging
the new files into the existing set (--replace-files to swap them out instead).
Other releases on the item are never touched, and a hidden release stays hidden —
the API resets hidden when an update omits it, so publish always sends it back
explicitly.
tmc release list --mod 5
tmc release files --mod 5 --version 1.2.0
tmc release rm --mod 5 --version 1.0.0Files on their own:
tmc file upload 'dist/*.zip' # batched past the API's 20-per-request cap
tmc file upload big.zip --raw # body-is-the-file form
tmc file get <id>
tmc file update <id> --title "Renamed"
tmc file download <id> -O ./here/
tmc file rm <id>Per-file size limits are the key owner's own — 20 MB standard, 1 GB for
supporters. Anything over 20 MB gets a local warning; the server has the final
say and answers 413.
Relations (tags, media, releases, links, sources, items) can be
managed without re-sending the whole item. The verbs say what they do:
| Command | Method | Meaning |
|---|---|---|
tmc rel add |
POST | merge these in, leave everything else |
tmc rel set |
PUT | this is now the complete set |
tmc rel rm |
DELETE | drop the ones you named |
tmc rel clear |
DELETE | drop all of them |
tmc tags add mod 5 pvp vanilla
tmc tags rm mod 5 pvp
tmc media add mod 5 --file shot.png --title Screenshot # uploads, then attaches
tmc media add mod 5 --url https://example.com/a.png
tmc links add mod 5 https://github.com/me/repo --type GITHUB
tmc rel get mod 5 releases
tmc rel add mod 5 sources --member '{"sourceId": 3, "path": "/mods/mine"}'
tmc rel set mod 5 media --from-file gallery.jsontmc template media prints a starter array for --from-file.
The same warning the API's docs give applies to the item body: setting --tag,
or a media/releases array via --set-json, replaces that relation
wholesale. The CLI says so before it sends.
tmc mod create --from-file mods.json # any length; batched into 25s
tmc mod update --from-file edits.json # each element needs an "id"
tmc mod delete 1 2 3 … # batched into 100s
tmc mod list --mine --all -o ids | xargs tmc mod deleteBulk writes are not transactional server-side. If a batch fails partway, the CLI reports the failing element's index and lists what did get written, so you can resume rather than start over.
-o table (default), json, jsonl, csv, tsv, yaml, ids. --field id,name keeps only the columns you want.
Data goes to stdout, progress and errors to stderr, so pipes stay clean:
tmc mod list --mine --all -o ids | xargs -n1 tmc mod get -o jsonExit codes: 0 ok, 2 usage, 3 auth (401/403), 4 not found, 5 validation
(400/413), 6 rate limited, 7 server error, 8 network.
- A fresh JWT assertion per request attempt. A
jtiis consumed on first use, so a retry that resent the same header would be a400. Signing happens inside the retry loop. - Rate limits. A
429carriesretry-afterwhere the API sets one and names its own wait in prose where it does not; the CLI waits exactly that long, up to--retry-wait-max(120s), then tells you how long is left instead of hanging a build. 5xx and connection failures get exponential backoff, and only on idempotent methods — a POST is never silently repeated. - Every server cap. 25 items per bulk write, 100 per bulk delete, 200 relation members on a PUT/POST, 500 keys on a relation DELETE, 20 upload parts. Pass whatever you have; it gets batched.
- Pagination.
--allwalks it,--maxstops early. - Streaming uploads. A 1 GB file is read from disk in chunks, not into RAM.
This CLI mirrors the API's field list locally so it can catch typos before spending a request — which means it can be one deploy behind. The site publishes what it actually accepts, so the mirror does not have to guess:
tmc contract sync # fetch it (no key needed) and cache it
tmc contract show # what is cached, and whether it is in use
tmc contract drift # what the site has that this CLI does not
tmc contract clear # forget itAfter a sync, --set coercion, the unknown-field check, tmc schema and the
completion scripts all answer from the site's field list instead of this
build's. The commands do not change: --help has to work with no network, and a
tool whose command list depends on the last successful fetch is a tool whose
--help differs between two machines. So sync keeps this accurate about
fields, and drift tells you when the tool itself needs a release.
A cached contract is used only against the site it came from, and only for 30 days. Two profiles pointed at production and a dev checkout have different registries, and judging one by the other's field list is worse than having none.
drift exits 3 when the two disagree, so CI can run it:
tmc contract drift --fetch --base-url https://moddingcommunity.comIt reports three things separately, because they mean different things:
| It says | What to do |
|---|---|
| fields the site has that this CLI does not | nothing — a synced contract already covers them |
| commands the site has that this CLI does not | release this CLI; tmc raw reaches them today |
| fields this CLI is stricter about | nothing — this CLI refuses a float for appId and the server would take one |
The document is GET <base>/api/content/spec and needs no credential — it is
the one thing a fresh install runs before tmc auth login. Its shape is in
cli-contract.md.
Nothing is ever blocked by a stale mirror, contract or no contract:
tmc mod create --set someNewField=1 --allow-unknown-fields
tmc raw PUT /api/content/mod/5/releases --json @releases.jsonThere is a second implementation of this same command grammar: the web
console at /console on the site, and in the corner of every page. It runs as
your signed-in session rather than as a key, so there is nothing to log into —
and it is the thing the contract keeps this tool in step with.
tmc catalog reads what a visitor sees, from the app API's public half
(/api/app/v1). It sends no credential — so it works before tmc auth login — and it covers what the content API does not hand out: categories,
tags, owners, download/rating counts, dependencies, reviews, the games list.
tmc catalog browse mod --app 3 --category 7 --tag 1 --tag 2 --sort downloads
tmc catalog browse server --filter onlineOnly=true --filter os=LINUX --all --max 200
tmc catalog show mod 42 # summary + counts
tmc catalog show mod 42 --part dependencies # or releases, media, links
tmc catalog facets mod # game/category ids to filter by
tmc catalog reviews mod 42 --sort helpful
tmc catalog games --search seed
tmc catalog lookup play.example.com --port 27015 # listed servers at an addressKinds are the app API's: asset mod server serverMap article community collection user. Ids on this surface are strings.
tmc defcon reads the public half of Defcon, the network monitor — exactly
what the site's /status page shows, from the same tRPC procedures
(defcon.public.status|series|mtr). No credential; asked of the website
origin (see open above), since tRPC is not served on the API one.
tmc defcon status # overall verdict, counts, open incidents
tmc defcon status --check # exit 0 OK · 1 DEGRADED · 2 DOWN · 3 UNKNOWN
tmc defcon monitors --status DOWN --status DEGRADED
tmc defcon monitor homepage # by id or (part of) name: per-node latency now
tmc defcon nodes # monitoring nodes, when the site publishes them
tmc defcon incidents # open incidents
tmc defcon latency 3 --range week --summary # per node: latest, mean, peak ms
tmc defcon latency 3 --range day --node frankfurt -o csv
tmc defcon mtr 5 # latest traceroute of an MTR monitorThe site's own switches are honoured: when the status page hides nodes or incidents, so does this. Resolved incidents are not public, so there is no incident history here.
tmc completion bash > /etc/bash_completion.d/tmc
tmc completion zsh > "${fpath[1]}/_tmc"
tmc completion fish > ~/.config/fish/completions/tmc.fishpython -m unittest discover -s testsThe tests run the real CLI against an in-process mock of the API over a real
socket, covering both auth modes (including Ed25519 against RFC 8032 vectors and
an openssl-generated key), the anonymous surface, batching, relation
merge/replace semantics, the release workflow, the retry path, open, the
public catalogue and Defcon (tests/test_public.py).
The default is https://api.moddingcommunity.com — the public APIs now live on
an origin of their own. The old address still works: the apex proxies
/api/content to the same service rather than redirecting to it, deliberately,
because a cross-origin redirect strips the Authorization header and would have
turned every keyed call into a 401. So a profile saved before the move keeps
working and there is nothing to migrate; --base-url still points the tool at a
staging site, a container or a dev checkout.
The docs print the short form api.moddingcommunity.com/content/..., but this
tool sends /api/content/.... That is the route's real name — the short spelling
exists because nginx rewrites the prefix back on, so it is the one that stops
working the moment you point --base-url straight at a container or a dev
checkout with no proxy in front of it. The prefixed form answers on every one of
them.
The API this speaks to is documented in the website repo at
docs/api/public-content-api.md; the implementation is under
src/lib/api/public/.
tmc catalog speaks to the app API's public reads (src/app/api/app/v1/,
query and response shapes in src/types/app-api/contract.ts); tmc defcon to
src/server/api/routers/defcon/public.ts, described in docs/defcon.md. The
command grammar the web console shares with this tool is published at
/api/content/spec (docs/api/cli-contract.md).